diff --git a/.env.example b/.env.example index 2a2f322d3..5fce552ab 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,12 @@ EXPRESS_SESSION_SECRET_KEY = "" # Google maps GOOGLE_API_KEY = "" GOOGLE_API_KEY_DEV = "" +# Google Map ID, required to render the maps (uses vector maps and +# AdvancedMarker). Create one in the Google Cloud Console under "Map Management" +# and associate it with a vector map style. See: +# https://developers.google.com/maps/documentation/get-map-id +# https://developers.google.com/maps/documentation/javascript/advanced-markers/migration +GOOGLE_MAP_ID = "" # Passwordless login MAGIC_LINK_SECRET_KEY = "" diff --git a/CHANGELOG.md b/CHANGELOG.md index 097da8be4..aaa0a28af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING**: The generator's `Labels` sheet in the Excel file should rename the "section" and "path" columns to "namespace" and "key", which better represents where we want the key to be (fixes [#1530](https://github.com/chairemobilite/evolution/issues/1530)). - **BREAKING**: The generator now use the question name as translation label key instead of the path. Surveys using the generator will automatically update all label fields, but if additional label with context had been added in the "Labels" sheet to match the question's original label, these might need to be updated (fixes [#1530](https://github.com/chairemobilite/evolution/issues/1530)). - **BREAKING**: Generator inactive widgets: `active` now behaves like a real boolean (Excel `0`/`1` included), defaults to inactive when omitted, and inactive rows stay commented-out in generated widget files instead of emitting broken code (fixes [#1597](https://github.com/chairemobilite/evolution/issues/1597)). +- **BREAKING**: The Google map widgets were migrated from the deprecated `@react-google-maps/api` package to `@vis.gl/react-google-maps`, which renders vector maps and uses `AdvancedMarker` (fixes [#453](https://github.com/chairemobilite/evolution/issues/453)). This requires a new `GOOGLE_MAP_ID` environment variable: each deployment must create a Map ID in the Google Cloud Console ("Map Management", associated with a vector map style) and set it in `.env` (`GOOGLE_MAP_ID`). Partners who provide us with a Google API key now also need to provide (or let us create) a Map ID tied to the same Google Cloud project; without it the maps will not render. See `.env.example` and the [Get a Map ID](https://developers.google.com/maps/documentation/get-map-id) docs. ### Deprecated @@ -80,6 +81,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Dependency updates +- Replaced `@react-google-maps/api` with `@vis.gl/react-google-maps` (and its transitive `@types/google.maps`) for the Google map widgets (fixes [#453](https://github.com/chairemobilite/evolution/issues/453)) - style-loader: 4.0.0 - lodash: 4.17.21 => 4.18.1 - @types/lodash: 4.17.21 => 4.17.23 diff --git a/packages/evolution-frontend/package.json b/packages/evolution-frontend/package.json index 6017262ec..f2e6ac053 100644 --- a/packages/evolution-frontend/package.json +++ b/packages/evolution-frontend/package.json @@ -36,8 +36,8 @@ "@fortawesome/react-fontawesome": "^3.1.1", "@luma.gl/core": "^9.1.9", "@luma.gl/shadertools": "^9.1.9", - "@react-google-maps/api": "^2.20.6", "@turf/turf": "^7.3.5", + "@vis.gl/react-google-maps": "^1.8.3", "@zeit/fetch-retry": "^5.0.1", "bowser": "^2.11.0", "chaire-lib-common": "^0.2.2", diff --git a/packages/evolution-frontend/src/components/inputs/__tests__/InputMapFindPlace.test.tsx b/packages/evolution-frontend/src/components/inputs/__tests__/InputMapFindPlace.test.tsx index 0079b4a7b..f53356f76 100644 --- a/packages/evolution-frontend/src/components/inputs/__tests__/InputMapFindPlace.test.tsx +++ b/packages/evolution-frontend/src/components/inputs/__tests__/InputMapFindPlace.test.tsx @@ -90,7 +90,7 @@ describe('Render InputMapPoint with various parameters', () => { url: 'path/to/selected-icon', size: [90, 90] as [number, number] }, - maxGeocodingResultsBounds: function (interview, path) { + maxGeocodingResultsBounds: function (_interview, _path) { return [{ lat: 45.2229, lng: -74.3230 }, { lat: 46.1181, lng: -72.9215 }] as [{ lat: number; lng: number; }, { lat: number; lng: number; }]; }, invalidGeocodingResultTypes: [ @@ -155,279 +155,196 @@ describe('Test geocoding requests', () => { properties: { placeData: { place_id: '3', formatted_address: 'Montreal, QC, Canada', types: ['locality', 'political'] } } }; + /** + * Render the widget with shared defaults. Pass `widgetConfig` to override. + */ + const renderWidget = (overrides: { widgetConfig?: any } = {}) => + render( + + ); + beforeEach(() => { mockedGeocode.mockClear(); mockOnValueChange.mockClear(); }); - test('Geocode with multiple results', async () => { - - const { container } = render(); + test('Geocode with multiple results: shows them in the list, no auto-select, no confirm', async () => { + const { container } = renderWidget(); const user = userEvent.setup(); // Find and click on the Geocode button, to return multiple values mockedGeocode.mockResolvedValueOnce([placeFeature1, placeFeature2]); await user.click(screen.getByText('Geocode')); - expect(mockedGeocode).toHaveBeenCalledTimes(1); expect(mockedGeocode).toHaveBeenCalledWith(geocodingString, expect.anything()); - // The select list should display 4 children (2 places and the 2 extra elements) and should not have a confirm button - const selectionList = container.querySelector('select'); + // The select list should display the 2 places and the empty element and should not have a confirm button + const selectionList = container.querySelector('select') as HTMLSelectElement; expect(selectionList).toBeInTheDocument(); - const selectionDomElement = selectionList as HTMLSelectElement; - expect(selectionDomElement.children.length).toEqual(3); - expect(selectionDomElement.children[1].textContent).toEqual('Foo extra good restaurant (123 test street)'); - expect(selectionDomElement.children[2].textContent).toEqual('123 foo street'); - + expect(selectionList.children.length).toEqual(3); + expect(selectionList.children[1].textContent).toEqual('Foo extra good restaurant (123 test street)'); + expect(selectionList.children[2].textContent).toEqual('123 foo street'); expect(screen.queryByText('ConfirmLocation')).not.toBeInTheDocument(); // Make sure the value has not been changed expect(mockOnValueChange).not.toHaveBeenCalled(); - }); - test('Geocode with single results, and confirm result', async () => { - - const { container } = render(); - + test('Multi-result: user picks the second option, then confirm saves that place_id', async () => { + const { container } = renderWidget(); const user = userEvent.setup(); - // Find and click on the Geocode button, to return a single result - mockedGeocode.mockResolvedValueOnce([placeFeature1]); + // Find and click on the Geocode button, to return multiple values + mockedGeocode.mockResolvedValueOnce([placeFeature1, placeFeature2]); await user.click(screen.getByText('Geocode')); - expect(mockedGeocode).toHaveBeenCalledTimes(1); - expect(mockedGeocode).toHaveBeenCalledWith(geocodingString, expect.anything()); - const selectionList = container.querySelector('select'); - expect(selectionList).toBeInTheDocument(); - const selectionDomElement = selectionList as HTMLSelectElement; - // The single and an empty choice - expect(selectionDomElement.children.length).toEqual(2); - expect(selectionDomElement.children[1].textContent).toEqual('Foo extra good restaurant (123 test street)'); + const select = container.querySelector('select') as HTMLSelectElement; + + // Pick the second result (place_id '2') in the selection list + await user.selectOptions(select, placeFeature2.properties.placeData.place_id); + expect(screen.getByText('ConfirmLocation')).toBeInTheDocument(); // Click on the confirm button and make sure the update function has been called await user.click(screen.getByText('ConfirmLocation')); - expect(mockOnValueChange).toHaveBeenCalledTimes(1); - expect(mockOnValueChange).toHaveBeenCalledWith({ target: { value: { - type: 'Feature' as const, - geometry: placeFeature1.geometry, - properties: { - lastAction: 'findPlace', - geocodingQueryString: geocodingString, - geocodingResultsData: { - formatted_address: placeFeature1.properties.placeData.formatted_address, - place_id: placeFeature1.properties.placeData.place_id, - types: undefined, - } + // The saved value must reference the SECOND place's place_id, not the first + expect(mockOnValueChange).toHaveBeenCalledWith({ + target: { + value: expect.objectContaining({ + properties: expect.objectContaining({ + geocodingResultsData: expect.objectContaining({ + place_id: placeFeature2.properties.placeData.place_id, + formatted_address: placeFeature2.properties.placeData.formatted_address, + types: placeFeature2.properties.placeData.types + }) + }) + }) } - } } }); - - // There should not be any selection or confirm widgets anymore - expect(container.querySelector('select')).not.toBeInTheDocument(); - expect(screen.queryByText('ConfirmLocation')).not.toBeInTheDocument(); + }); }); - test('Geocode with single result, then re-query with undefined results', async () => { - - const { container } = render(); - + test('Geocode with single result auto-selects it (but still requires a manual confirm), then confirm preserves place_id (legal compliance)', async () => { + const { container } = renderWidget(); const user = userEvent.setup(); - // Select and confirm button should not be present - expect(container.querySelector('select')).not.toBeInTheDocument(); - expect(screen.queryByText('ConfirmLocation')).not.toBeInTheDocument(); - // Find and click on the Geocode button, to return a single result mockedGeocode.mockResolvedValueOnce([placeFeature1]); await user.click(screen.getByText('Geocode')); - expect(mockedGeocode).toHaveBeenCalledTimes(1); - expect(mockedGeocode).toHaveBeenCalledWith(geocodingString, expect.anything()); - const selectionList = container.querySelector('select'); - expect(selectionList).toBeInTheDocument(); - - expect(screen.getByText('ConfirmLocation')).toBeInTheDocument(); - - // Click the geocode button again, but get undefined values - mockedGeocode.mockResolvedValueOnce(undefined); - const newGeocodingString = 'other string'; - testWidgetConfig.geocodingQueryString.mockReturnValueOnce(newGeocodingString); - await user.click(screen.getByText('Geocode')); - expect(mockedGeocode).toHaveBeenCalledTimes(2); - expect(mockedGeocode).toHaveBeenLastCalledWith(newGeocodingString, expect.anything()); - const selectionList2 = container.querySelector('select'); - expect(selectionList2).not.toBeInTheDocument(); + const selectionList = container.querySelector('select') as HTMLSelectElement; + // The single result is auto-selected: the list has the single result and an empty choice + expect(selectionList.children.length).toEqual(2); + expect(selectionList.children[1].textContent).toEqual('Foo extra good restaurant (123 test street)'); + // Click on the confirm button and make sure the update function has been called + await user.click(screen.getByText('ConfirmLocation')); + expect(mockOnValueChange).toHaveBeenCalledWith({ + target: { + value: { + type: 'Feature' as const, + geometry: placeFeature1.geometry, + properties: { + lastAction: 'findPlace', + geocodingQueryString: geocodingString, + geocodingResultsData: { + formatted_address: placeFeature1.properties.placeData.formatted_address, + place_id: placeFeature1.properties.placeData.place_id, + types: undefined + } + } + } + } + }); + // There should not be any selection or confirm widgets anymore + expect(container.querySelector('select')).not.toBeInTheDocument(); expect(screen.queryByText('ConfirmLocation')).not.toBeInTheDocument(); - }); - test('Geocode with single result, then re-query with rejection', async () => { - - const { container } = render(); + // Re-querying after a single result clears the selection list and confirm button, + // both when the new query yields no result and when it rejects. + test.each([ + ['undefined results', () => mockedGeocode.mockResolvedValueOnce(undefined)], + ['rejected promise', () => mockedGeocode.mockRejectedValueOnce('error geocoding')] + ])('Geocode with single result, then re-query with %s clears the list', async (_label, primeSecondCall) => { + const { container } = renderWidget(); const user = userEvent.setup(); - // Select and confirm button should not be present - expect(container.querySelector('select')).not.toBeInTheDocument(); - expect(screen.queryByText('ConfirmLocation')).not.toBeInTheDocument(); - // Find and click on the Geocode button, to return a single result mockedGeocode.mockResolvedValueOnce([placeFeature1]); await user.click(screen.getByText('Geocode')); - expect(mockedGeocode).toHaveBeenCalledTimes(1); - expect(mockedGeocode).toHaveBeenCalledWith(geocodingString, expect.anything()); - const selectionList = container.querySelector('select'); - expect(selectionList).toBeInTheDocument(); - + expect(container.querySelector('select')).toBeInTheDocument(); expect(screen.getByText('ConfirmLocation')).toBeInTheDocument(); - // Click the geocode button again, but throw an error - mockedGeocode.mockRejectedValueOnce('error geocoding'); + // Click the geocode button again, but get undefined results or a rejection const newGeocodingString = 'other string'; testWidgetConfig.geocodingQueryString.mockReturnValueOnce(newGeocodingString); + primeSecondCall(); await user.click(screen.getByText('Geocode')); - expect(mockedGeocode).toHaveBeenCalledTimes(2); expect(mockedGeocode).toHaveBeenLastCalledWith(newGeocodingString, expect.anything()); - const selectionList2 = container.querySelector('select'); - expect(selectionList2).not.toBeInTheDocument(); - - expect(screen.queryByText('ConfirmLocation')).not.toBeInTheDocument(); - - }); - - test('Click the geocode button, that triggers an update', async () => { - - const props = { - id: testId, - onValueChange: mockOnValueChange, - widgetConfig: testWidgetConfig, - value: { type: 'Feature' as const, properties: {}, geometry: { type: 'Point' as const, coordinates: [-73.1, 45.02] } }, - inputRef: React.createRef() as React.LegacyRef, - size: 'medium' as const, - interview: interviewAttributes, - user: userAttributes, - path: 'foo.test', - loadingState: 0, - }; - - const { container } = render(); - const user = userEvent.setup(); - - // Select and confirm button should not be present expect(container.querySelector('select')).not.toBeInTheDocument(); expect(screen.queryByText('ConfirmLocation')).not.toBeInTheDocument(); - - // Find and click on the Geocode button, which should trigger an update - mockedGeocode.mockResolvedValueOnce([placeFeature1]); - await user.click(screen.getByText('Geocode')); - expect(mockedGeocode).toHaveBeenCalledTimes(1); - expect(mockedGeocode).toHaveBeenCalledWith(geocodingString, expect.anything()); - const selectionList = container.querySelector('select'); - expect(selectionList).toBeInTheDocument(); - - expect(screen.getByText('ConfirmLocation')).toBeInTheDocument(); - }); - test('Geocode with single imprecise result', async () => { - const widgetConfig = Object.assign({ - invalidGeocodingResultTypes: [ - 'political', - 'country', - 'administrative_area_level_1', - 'administrative_area_level_2', - 'administrative_area_level_3', - 'administrative_area_level_4', - 'administrative_area_level_5', - 'administrative_area_level_6', - 'administrative_area_level_7', - 'colloquial_area', - 'locality', - 'sublocality', - 'sublocality_level_1', - 'neighborhood', - 'route' - ] - }, testWidgetConfig); - - const { container } = render(); - const user = userEvent.setup(); - - + test('Geocode with single imprecise result auto-confirms with isGeocodingImprecise flag', async () => { + const widgetConfig = Object.assign( + { + invalidGeocodingResultTypes: [ + 'political', + 'country', + 'administrative_area_level_1', + 'administrative_area_level_2', + 'administrative_area_level_3', + 'administrative_area_level_4', + 'administrative_area_level_5', + 'administrative_area_level_6', + 'administrative_area_level_7', + 'colloquial_area', + 'locality', + 'sublocality', + 'sublocality_level_1', + 'neighborhood', + 'route' + ] + }, + testWidgetConfig + ); - // Select and confirm button should not be present - expect(container.querySelector('select')).not.toBeInTheDocument(); - expect(screen.queryByText('ConfirmLocation')).not.toBeInTheDocument(); + const { container } = renderWidget({ widgetConfig }); + const user = userEvent.setup(); - // Find and click on the Geocode button, to return a single but imprecise value (according to testWidgetConfig.invalidGeocodingResultTypes) + // Find and click on the Geocode button, to return a single but imprecise value (according to widgetConfig.invalidGeocodingResultTypes) mockedGeocode.mockResolvedValueOnce([placeFeature3]); await user.click(screen.getByText('Geocode')); - expect(mockedGeocode).toHaveBeenCalledTimes(1); expect(mockedGeocode).toHaveBeenCalledWith(geocodingString, expect.anything()); - expect(mockOnValueChange).toHaveBeenCalledWith({ target: { value: { - type: 'Feature' as const, - geometry: placeFeature3.geometry, - properties: { - lastAction: 'findPlace', - geocodingQueryString: geocodingString, - geocodingResultsData: { - formatted_address: placeFeature3.properties.placeData.formatted_address, - place_id: placeFeature3.properties.placeData.place_id, - types: placeFeature3.properties.placeData.types, - }, - isGeocodingImprecise: true, // key part! + expect(mockOnValueChange).toHaveBeenCalledWith({ + target: { + value: { + type: 'Feature' as const, + geometry: placeFeature3.geometry, + properties: { + lastAction: 'findPlace', + geocodingQueryString: geocodingString, + geocodingResultsData: { + formatted_address: placeFeature3.properties.placeData.formatted_address, + place_id: placeFeature3.properties.placeData.place_id, + types: placeFeature3.properties.placeData.types + }, + isGeocodingImprecise: true // key part! + } + } } - } } }); - + }); // Select list and confirm button should not be present expect(container.querySelector('select')).not.toBeInTheDocument(); expect(screen.queryByText('ConfirmLocation')).not.toBeInTheDocument(); diff --git a/packages/evolution-frontend/src/components/inputs/__tests__/InputMapPoint.test.tsx b/packages/evolution-frontend/src/components/inputs/__tests__/InputMapPoint.test.tsx index 232b1e06f..ed208c43d 100644 --- a/packages/evolution-frontend/src/components/inputs/__tests__/InputMapPoint.test.tsx +++ b/packages/evolution-frontend/src/components/inputs/__tests__/InputMapPoint.test.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event' +import userEvent from '@testing-library/user-event'; import '@testing-library/jest-dom'; import { interviewAttributes } from './interviewData'; @@ -27,13 +27,13 @@ const mockedGeocode = geocodeSinglePoint as jest.MockedFunction true, is_admin: false, pages: [], showUserInfo: true -} +}; const baseWidgetConfig = { type: 'question' as const, @@ -41,8 +41,8 @@ const baseWidgetConfig = { path: 'test.foo', containsHtml: true, label: { - fr: `Texte en français`, - en: `English text` + fr: 'Texte en français', + en: 'English text' }, inputType: 'mapPoint' as const, size: 'medium' as const, @@ -55,7 +55,7 @@ describe('Render InputMapPoint with various parameters', () => { const { container } = render( { /* nothing to do */}} + onValueChange={() => { /* nothing to do */ }} widgetConfig={baseWidgetConfig} value={undefined} inputRef={React.createRef()} @@ -68,12 +68,12 @@ describe('Render InputMapPoint with various parameters', () => { }); test('Test with all parameters', () => { - + const testWidgetConfig = Object.assign({ geocodingQueryString: jest.fn(), refreshGeocodingLabel: { - fr: `Rafraîchir la carte`, - en: `Refresh map` + fr: 'Rafraîchir la carte', + en: 'Refresh map' }, icon: { url: 'path/to/icon', @@ -85,9 +85,9 @@ describe('Render InputMapPoint with various parameters', () => { const { container } = render( { /* nothing to do */}} + onValueChange={() => { /* nothing to do */ }} widgetConfig={testWidgetConfig} - value={{ type: 'Feature' as const, properties: {}, geometry: { type: 'Point' as const, coordinates: [-73.1, 45.02]}}} + value={{ type: 'Feature' as const, properties: {}, geometry: { type: 'Point' as const, coordinates: [-73.1, 45.02] } }} inputRef={React.createRef()} interview={interviewAttributes} user={userAttributes} @@ -96,7 +96,7 @@ describe('Render InputMapPoint with various parameters', () => { ); expect(container).toMatchSnapshot(); }); - + }); describe('Test geocoding requests', () => { @@ -107,8 +107,8 @@ describe('Test geocoding requests', () => { const testWidgetConfig = Object.assign({ geocodingQueryString: jest.fn().mockReturnValue(geocodingString), refreshGeocodingLabel: { - fr: `Geocode`, - en: `Geocode` + fr: 'Geocode', + en: 'Geocode' }, icon: { url: 'path/to/icon', @@ -123,93 +123,57 @@ describe('Test geocoding requests', () => { resetToDefaultUnlessUserInteracted: true }, baseWidgetConfig); - const geocodedFeature = { - type: 'Feature' as const, + const geocodedFeature = { + type: 'Feature' as const, geometry: { type: 'Point' as const, coordinates: [-73.2, 45.1] }, - properties: { geocodingResultMetadata: { formattedAddress: '123 foo street, Montreal, QC' }, lastAction: 'geocoding', geocodingQueryString: geocodingString } + properties: { + geocodingResultMetadata: { formattedAddress: '123 foo street, Montreal, QC' }, + lastAction: 'geocoding', + geocodingQueryString: geocodingString + } }; - + + const renderWidget = () => + render( + + ); beforeEach(() => { mockedGeocode.mockClear(); mockOnValueChange.mockClear(); }); - test('Geocode single result', async () => { - - render(); - const user = userEvent.setup(); - - // Find and click on the Geocode button, to return a single result - mockedGeocode.mockResolvedValueOnce(geocodedFeature); - await user.click(screen.getByText('Geocode')); - expect(mockedGeocode).toHaveBeenCalledTimes(1); - expect(mockedGeocode).toHaveBeenCalledWith(geocodingString, expect.anything()); - - // Make sure the value was changed to the geocoded feature - expect(mockOnValueChange).toHaveBeenCalledTimes(1); - expect(mockOnValueChange).toHaveBeenCalledWith({ target: { value: geocodedFeature }}); - - }); - - test('Geocode with undefined', async () => { - - render(); + // Whatever the geocoding outcome, clicking Geocode forwards a single update + // call to onValueChange with the matching value (resolved feature or undefined). + test.each<[string, () => void, GeoJSON.Feature | undefined]>([ + ['resolved feature', () => mockedGeocode.mockResolvedValueOnce(geocodedFeature), geocodedFeature], + ['undefined result', () => mockedGeocode.mockResolvedValueOnce(undefined), undefined], + ['rejected promise', () => mockedGeocode.mockRejectedValueOnce('error geocoding'), undefined] + ])('Geocode click with %s triggers a single onValueChange', async (_label, primeMock, expected) => { + renderWidget(); const user = userEvent.setup(); - // Find and click on the Geocode button, to return undefined values - mockedGeocode.mockResolvedValueOnce(undefined); + // Find and click on the Geocode button, to return the prepared result (feature, undefined or rejection) + primeMock(); await user.click(screen.getByText('Geocode')); - expect(mockedGeocode).toHaveBeenCalledTimes(1); - expect(mockedGeocode).toHaveBeenCalledWith(geocodingString, expect.anything()); - // Make sure the value was changed to undefined - expect(mockOnValueChange).toHaveBeenCalledTimes(1); - expect(mockOnValueChange).toHaveBeenCalledWith({ target: { value: undefined }}); - - }); - - test('Geocode with rejection', async () => { - - render(); - const user = userEvent.setup(); - - // Find and click on the Geocode button, with a rejected promise - mockedGeocode.mockRejectedValueOnce('error geocoding'); - await user.click(screen.getByText('Geocode')); expect(mockedGeocode).toHaveBeenCalledTimes(1); expect(mockedGeocode).toHaveBeenCalledWith(geocodingString, expect.anything()); - - // Make sure the value was changed to undefined + // Make sure the value was changed to the expected feature (or undefined on no result / error) expect(mockOnValueChange).toHaveBeenCalledTimes(1); - expect(mockOnValueChange).toHaveBeenCalledWith({ target: { value: undefined }}); + expect(mockOnValueChange).toHaveBeenCalledWith({ target: { value: expected } }); }); - }); diff --git a/packages/evolution-frontend/src/components/inputs/maps/google/GoogleMapOverlays.tsx b/packages/evolution-frontend/src/components/inputs/maps/google/GoogleMapOverlays.tsx new file mode 100644 index 000000000..1915c6fe5 --- /dev/null +++ b/packages/evolution-frontend/src/components/inputs/maps/google/GoogleMapOverlays.tsx @@ -0,0 +1,81 @@ +/* + * Copyright Polytechnique Montreal and contributors + * + * This file is licensed under the MIT License. + * License text available at https://opensource.org/licenses/MIT + */ + +/** + * Lightweight React wrappers around `google.maps.Polyline` and + * `google.maps.Polygon`, mounted onto the current `` via `useMap()`. + * + * `@vis.gl/react-google-maps` does not ship Polyline/Polygon components, so we + * recreate the small subset used by the survey widgets imperatively. The + * underlying overlay is created once per map instance and disposed on unmount; + * options are pushed via `setOptions` whenever the prop changes. + */ + +import React from 'react'; +import { useMap } from '@vis.gl/react-google-maps'; + +/** + * Renders a `google.maps.Polyline` on the enclosing ``. + * + * @param props.options Polyline options forwarded to `setOptions`. + */ +export const MapPolyline: React.FC<{ options: google.maps.PolylineOptions }> = ({ options }) => { + const map = useMap(); + const polylineRef = React.useRef(null); + + // Use a ref so the create-effect can read the latest options on mount + // without depending on `options` (which would re-create the overlay on + // every option change instead of just calling setOptions). + const optionsRef = React.useRef(options); + optionsRef.current = options; + + React.useEffect(() => { + if (!map) return; + const polyline = new google.maps.Polyline({ ...optionsRef.current, map }); + polylineRef.current = polyline; + return () => { + polyline.setMap(null); + polylineRef.current = null; + }; + }, [map]); + + React.useEffect(() => { + polylineRef.current?.setOptions(options); + }, [options]); + + return null; +}; + +/** + * Renders a `google.maps.Polygon` on the enclosing ``. + * + * @param props.options Polygon options forwarded to `setOptions` (including + * `paths`). + */ +export const MapPolygon: React.FC<{ options: google.maps.PolygonOptions }> = ({ options }) => { + const map = useMap(); + const polygonRef = React.useRef(null); + + const optionsRef = React.useRef(options); + optionsRef.current = options; + + React.useEffect(() => { + if (!map) return; + const polygon = new google.maps.Polygon({ ...optionsRef.current, map }); + polygonRef.current = polygon; + return () => { + polygon.setMap(null); + polygonRef.current = null; + }; + }, [map]); + + React.useEffect(() => { + polygonRef.current?.setOptions(options); + }, [options]); + + return null; +}; diff --git a/packages/evolution-frontend/src/components/inputs/maps/google/InfoMapGoogle.tsx b/packages/evolution-frontend/src/components/inputs/maps/google/InfoMapGoogle.tsx index 70e80f161..a83d66abd 100644 --- a/packages/evolution-frontend/src/components/inputs/maps/google/InfoMapGoogle.tsx +++ b/packages/evolution-frontend/src/components/inputs/maps/google/InfoMapGoogle.tsx @@ -1,78 +1,85 @@ /* - * Copyright 2023, Polytechnique Montreal and contributors + * Copyright Polytechnique Montreal and contributors * * This file is licensed under the MIT License. * License text available at https://opensource.org/licenses/MIT */ import React, { useEffect, JSX } from 'react'; import { useTranslation } from 'react-i18next'; -import { GoogleMap, useJsApiLoader, Marker, Polyline, Polygon, MarkerProps } from '@react-google-maps/api'; +import { + APIProvider, + AdvancedMarker, + Map, + MapCameraChangedEvent, + useApiIsLoaded, + useMap +} from '@vis.gl/react-google-maps'; import Markdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import projectConfig from 'chaire-lib-common/lib/config/shared/project.config'; -import { getCurrentGoogleMapConfig } from '../../../../config/googleMaps.config'; +import { getCurrentGoogleMapConfig, getGoogleMapId } from '../../../../config/googleMaps.config'; import * as surveyHelper from 'evolution-common/lib/utils/helpers'; import InputLoading from '../../InputLoading'; import { InfoMapProps } from '../types'; +import { MapPolygon, MapPolyline } from './GoogleMapOverlays'; const coordinatesToLatLng = (coordinates: number[]) => ({ lat: coordinates[1], lng: coordinates[0] }); -const InfoMap: React.FC = (props: InfoMapProps) => { +/** + * Render a non-draggable ``. Advanced markers require a Google + * Map ID to be configured (via `GOOGLE_MAP_ID`); see the README and + * `.env.example`. + */ +const InfoMarker: React.FC<{ + position: google.maps.LatLngLiteral; + icon?: { url: string; size: [number, number] }; +}> = ({ position, icon }) => ( + + {icon && } + +); + +const InfoMapInner: React.FC = (props) => { const { i18n } = useTranslation(); - // Set the google map config once, as it cannot be changed after it is - // loaded (for language change for example). see - // https://stackoverflow.com/questions/7065420/how-can-i-change-the-language-of-google-maps-on-the-run - const googleMapConfig = React.useMemo(() => getCurrentGoogleMapConfig(i18n.language), []); - const { isLoaded } = useJsApiLoader(googleMapConfig); + const isLoaded = useApiIsLoaded(); + const map = useMap(); - const [map, setMap] = React.useState(null); - const [center, setCenter] = React.useState<{ lat: number; lng: number } | google.maps.LatLng>({ + // Captured once on mount. `` is rendered uncontrolled + // (`defaultCenter`) and recentered programmatically via `map.panTo()` + // when the parsed config center changes. Passing a controlled `center` + // prop would re-snap the camera every render and freeze user pan. + const initialCenter = React.useRef<{ lat: number; lng: number }>({ lat: projectConfig.mapDefaultCenter.lat, lng: projectConfig.mapDefaultCenter.lon - }); - // Keep latest center accessible from onMapReady without re-creating the - // callback every render (GoogleMap's onLoad fires once on mount). - const latestCenterRef = React.useRef(center); - React.useEffect(() => { - latestCenterRef.current = center; - }, [center]); + }).current; useEffect(() => { - const configDefaultCenter = surveyHelper.parse( + if (!map) return; + const widgetDefaultCenter = surveyHelper.parse( props.widgetConfig.defaultCenter, props.interview, props.path, props.user ); - const defaultCenter = configDefaultCenter - ? { lat: configDefaultCenter.lat, lng: configDefaultCenter.lon } + const defaultCenter = widgetDefaultCenter + ? { lat: widgetDefaultCenter.lat, lng: widgetDefaultCenter.lon } : { lat: projectConfig.mapDefaultCenter.lat, lng: projectConfig.mapDefaultCenter.lon }; - setCenter(defaultCenter); - // `defaultCenter` may be a ParsingFunction whose result depends on the - // current interview/path/user, so we must re-run the effect when - // those change too. - }, [props.widgetConfig.defaultCenter, props.interview, props.path, props.user]); - - const onMapReady = React.useCallback((map: google.maps.Map) => { - map.panTo(latestCenterRef.current); - setMap(map); - }, []); + map.panTo(defaultCenter); + }, [map, props.widgetConfig.defaultCenter, props.interview, props.path, props.user]); - const onUnmount = React.useCallback(() => { - setMap(null); - }, []); - - const onZoomChange = () => { - if (!map) return; - const currentZoom = (map as google.maps.Map).getZoom(); - if (currentZoom && props.widgetConfig.maxZoom && props.widgetConfig.maxZoom < currentZoom) { - (map as google.maps.Map).setZoom(props.widgetConfig.maxZoom); - } - }; + const onZoomChange = React.useCallback( + (e: MapCameraChangedEvent) => { + const max = props.widgetConfig.maxZoom; + if (max && e.detail.zoom > max && map) { + map.setZoom(max); + } + }, + [map, props.widgetConfig.maxZoom] + ); // Memoize geojsons and the coordinates that define the map bounds so the // bounds-fitting effect below only runs when the underlying data changes. @@ -98,8 +105,8 @@ const InfoMap: React.FC = (props: InfoMapProps) => { return coords; }, [geojsons]); - // Fit the map to the data bounds only when the map instance or the - // underlying coordinates change, instead of on every render. + // Fit bounds only when the map instance or the underlying coordinates + // change, instead of on every render. useEffect(() => { if (!map || boundCoords.length === 0) return; const bounds = new google.maps.LatLngBounds(); @@ -131,19 +138,13 @@ const InfoMap: React.FC = (props: InfoMapProps) => { for (let i = 0, countI = points.length; i < countI; i++) { const point = points[i]; - const gLatLng = new google.maps.LatLng(point.geometry.coordinates[1], point.geometry.coordinates[0]); - const markerParams: MarkerProps = { - position: gLatLng, - draggable: false - }; - if (point.properties.icon) { - markerParams.icon = { - url: point.properties.icon.url, - size: new google.maps.Size(point.properties.icon.size[0], point.properties.icon.size[1]), - scaledSize: new google.maps.Size(point.properties.icon.size[0], point.properties.icon.size[1]) - }; - } - gMarkers.push(); + gMarkers.push( + + ); } for (let i = 0, countI = linestrings.length; i < countI; i++) { @@ -185,58 +186,43 @@ const InfoMap: React.FC = (props: InfoMapProps) => { }; gPolylines.push( - ); gPolylines.push( - ); gPolylines.push( - @@ -253,10 +239,10 @@ const InfoMap: React.FC = (props: InfoMapProps) => { ); gPolygons.push( - = (props: InfoMapProps) => { const polygonCoordinates = polyCoords.map((ring: number[][]) => ring.map(coordinatesToLatLng)); gPolygons.push( - = (props: InfoMapProps) => { } } + const mapId = getGoogleMapId(); + return (
{title}
- {gMarkers} {gPolylines} {gPolygons} - +
); }; +const InfoMap: React.FC = (props) => { + const { i18n } = useTranslation(); + // Set the google map config once, as it cannot be changed after it is + // loaded (for language change for example). see + // https://stackoverflow.com/questions/7065420/how-can-i-change-the-language-of-google-maps-on-the-run + const config = React.useMemo(() => getCurrentGoogleMapConfig(i18n.language), []); + + return ( + + + + ); +}; + export default InfoMap; diff --git a/packages/evolution-frontend/src/components/inputs/maps/google/InputMapGoogle.tsx b/packages/evolution-frontend/src/components/inputs/maps/google/InputMapGoogle.tsx index 471841eff..d29aad722 100644 --- a/packages/evolution-frontend/src/components/inputs/maps/google/InputMapGoogle.tsx +++ b/packages/evolution-frontend/src/components/inputs/maps/google/InputMapGoogle.tsx @@ -1,20 +1,28 @@ /* - * Copyright 2022, Polytechnique Montreal and contributors + * Copyright Polytechnique Montreal and contributors * * This file is licensed under the MIT License. * License text available at https://opensource.org/licenses/MIT */ import React from 'react'; -import { WithTranslation, withTranslation } from 'react-i18next'; -import { GoogleMap, useJsApiLoader, Marker } from '@react-google-maps/api'; +import { useTranslation } from 'react-i18next'; +import { + APIProvider, + AdvancedMarker, + Map, + MapCameraChangedEvent, + MapMouseEvent, + useApiIsLoaded, + useMap +} from '@vis.gl/react-google-maps'; import GeoJSON from 'geojson'; import bowser from 'bowser'; -import { getCurrentGoogleMapConfig } from '../../../../config/googleMaps.config'; +import { getCurrentGoogleMapConfig, getGoogleMapId } from '../../../../config/googleMaps.config'; import InputLoading from '../../InputLoading'; -import { FeatureGeocodedProperties, MarkerData, InfoWindow } from '../types'; -import { geojson, toLatLng } from './GoogleMapUtils'; +import { FeatureGeocodedProperties, MarkerData, InfoWindow as InfoWindowData } from '../types'; +import { geojson } from './GoogleMapUtils'; import { logClientSideMessage } from '../../../../services/errorManagement/errorHandling'; export interface InputGoogleMapPointProps { @@ -23,19 +31,20 @@ export interface InputGoogleMapPointProps { value?: GeoJSON.Feature; defaultZoom?: number; maxZoom?: number; - height?: string; // the height of the map container in css units: example: 28rem or 550px + /** Height of the map container, in any css unit (e.g. `28rem`, `550px`). */ + height?: string; markers: MarkerData[]; onValueChange: (feature: GeoJSON.Feature | undefined) => void; onMapReady?: (bbox?: [number, number, number, number]) => void; onBoundsChanged?: (bbox?: [number, number, number, number]) => void; - // Change this value when map bounds should be increased to fit markers + /** Increment to request the map to fit its bounds to the current markers. */ shouldFitBounds?: number; - infoWindow?: InfoWindow; + infoWindow?: InfoWindowData; setGeocodingOptions?: (options: { [key: string]: unknown }) => void; } const callWithBounds = ( - bounds?: google.maps.LatLngBounds, + bounds: google.maps.LatLngBounds | null | undefined, call?: (bbox?: [number, number, number, number]) => void ) => { if (!call) { @@ -53,100 +62,135 @@ const callWithBounds = ( ); }; -const InputMapGoogle: React.FunctionComponent = ( - props: InputGoogleMapPointProps & WithTranslation -) => { - // Set the google map config once, as it cannot be changed after it is - // loaded (for language change for example). see - // https://stackoverflow.com/questions/7065420/how-can-i-change-the-language-of-google-maps-on-the-run - const googleMapConfig = React.useMemo(() => getCurrentGoogleMapConfig(props.i18n.language), []); - const { isLoaded, loadError } = useJsApiLoader(googleMapConfig); - if (loadError !== undefined) { - const browserTechData = bowser.getParser(window.navigator.userAgent).parse(); - const errorMessage = `Google Maps API could not be loaded. Browser: ${JSON.stringify(browserTechData)}, error: ${loadError.message}`; - logClientSideMessage(errorMessage); - } +/** + * Renders a single ``. Advanced markers require a Google Map ID + * to be configured (via `GOOGLE_MAP_ID`); see the README and `.env.example`. + */ +const MapMarker: React.FunctionComponent<{ + markerData: MarkerData; + onDragEnd?: (e: google.maps.MapMouseEvent) => void; +}> = ({ markerData, onDragEnd }) => { + const position = { + lat: markerData.position.geometry.coordinates[1], + lng: markerData.position.geometry.coordinates[0] + }; + return ( + + + + ); +}; - const [map, setMap] = React.useState(null); - const [center, setCenter] = React.useState<{ lat: number; lng: number } | google.maps.LatLng>({ +const InputMapGoogleInner: React.FunctionComponent = (props) => { + const isLoaded = useApiIsLoaded(); + const map = useMap(); + + // Captured once. `` is rendered uncontrolled (defaultCenter), and we + // pan programmatically via `map.panTo()` afterwards. Passing a controlled + // `center` prop would re-snap the camera every render and freeze user pan. + const initialCenter = React.useRef({ lat: props.defaultCenter.lat, lng: props.defaultCenter.lon - }); + }).current; const [placesInfoWindow, setPlacesInfoWindow] = React.useState(undefined); const changeValueAndPan = React.useCallback( (feature: GeoJSON.Feature | undefined) => { props.onValueChange(feature); - if (feature) { - setCenter({ lat: feature.geometry.coordinates[1], lng: feature.geometry.coordinates[0] }); - (map as google.maps.Map).panTo({ + if (feature && map) { + map.panTo({ lat: feature.geometry.coordinates[1], lng: feature.geometry.coordinates[0] }); } }, - [map] + [map, props.onValueChange] ); - const onLoad = React.useCallback((map: google.maps.Map) => { - setMap(map); - if (props.setGeocodingOptions) props.setGeocodingOptions({ map }); - callWithBounds(map.getBounds(), props.onMapReady); - setPlacesInfoWindow( - new google.maps.InfoWindow({ - pixelOffset: new google.maps.Size(0, -40) - }) - ); - }, []); - - const onUnmount = React.useCallback(() => { - setMap(null); - if (props.setGeocodingOptions) props.setGeocodingOptions({ map: undefined }); - }, []); - - const onPositionChange = React.useCallback( - (e: google.maps.MapMouseEvent, triggerEvent: string) => { - const geojsonValue = geojson(e.latLng); + const recordPositionChange = React.useCallback( + (latLng: google.maps.LatLng | null | undefined, triggerEvent: string) => { + if (!map) return; + const geojsonValue = geojson(latLng); if (geojsonValue) { geojsonValue.properties.lastAction = triggerEvent; - geojsonValue.properties.zoom = (map as google.maps.Map).getZoom(); + geojsonValue.properties.zoom = map.getZoom(); geojsonValue.properties.platform = bowser.getParser(window.navigator.userAgent).getPlatformType(); } changeValueAndPan(geojsonValue); }, - [map] + [map, changeValueAndPan] ); - const onZoomChange = () => { - if (!map) return; - const currentZoom = (map as google.maps.Map).getZoom(); - if (currentZoom && props.maxZoom && props.maxZoom < currentZoom) { - (map as google.maps.Map).setZoom(props.maxZoom); - } - }; + // click: vis.gl wraps the event with `.detail.latLng` as a literal. + const onMapClick = React.useCallback( + (e: MapMouseEvent) => { + const latLng = e.detail.latLng ? new google.maps.LatLng(e.detail.latLng) : null; + recordPositionChange(latLng, 'mapClicked'); + }, + [recordPositionChange] + ); + + // / drag: native google.maps.MapMouseEvent with `.latLng`. + const onMarkerDragEnd = React.useCallback( + (e: google.maps.MapMouseEvent) => { + recordPositionChange(e.latLng, 'markerDragged'); + }, + [recordPositionChange] + ); + + const onZoomChange = React.useCallback( + (e: MapCameraChangedEvent) => { + if (props.maxZoom && e.detail.zoom > props.maxZoom && map) { + map.setZoom(props.maxZoom); + } + }, + [map, props.maxZoom] + ); - const onBoundsChanged = () => { + const onBoundsChanged = React.useCallback( + (e: MapCameraChangedEvent) => { + const sw = { lat: e.detail.bounds.south, lng: e.detail.bounds.west }; + const ne = { lat: e.detail.bounds.north, lng: e.detail.bounds.east }; + const bounds = new google.maps.LatLngBounds(sw, ne); + callWithBounds(bounds, props.onBoundsChanged); + }, + [props.onBoundsChanged] + ); + + // Wire up the map instance (geocoding context, infoWindow handle, onMapReady). + React.useEffect(() => { if (!map) return; - callWithBounds((map as google.maps.Map).getBounds(), props.onBoundsChanged); - }; + if (props.setGeocodingOptions) { + props.setGeocodingOptions({ map }); + } + callWithBounds(map.getBounds(), props.onMapReady); + setPlacesInfoWindow( + new google.maps.InfoWindow({ + pixelOffset: new google.maps.Size(0, -40) + }) + ); + return () => { + if (props.setGeocodingOptions) props.setGeocodingOptions({ map: undefined }); + }; + }, [map]); React.useEffect(() => { if (map) { - (map as google.maps.Map).panTo({ - lat: props.defaultCenter.lat, - lng: props.defaultCenter.lon - }); + map.panTo({ lat: props.defaultCenter.lat, lng: props.defaultCenter.lon }); } - }, [props.defaultCenter]); + }, [props.defaultCenter, map]); React.useEffect(() => { if (!map || !props.defaultZoom) return; - // If the zoom is changed, we need to zoom to that level - const currentZoom = (map as google.maps.Map).getZoom(); - if (currentZoom !== props.defaultZoom) { - (map as google.maps.Map).setZoom(props.defaultZoom); + if (map.getZoom() !== props.defaultZoom) { + map.setZoom(props.defaultZoom); } - }, [props.defaultZoom]); + }, [props.defaultZoom, map]); React.useEffect(() => { if (map && props.markers.length >= 1) { @@ -164,7 +208,6 @@ const InputMapGoogle: React.FunctionComponent { - if (!placesInfoWindow) { - return; - } + if (!placesInfoWindow) return; placesInfoWindow.close(); - if (props.infoWindow) { + if (props.infoWindow && map) { placesInfoWindow.setContent(props.infoWindow.content); placesInfoWindow.setPosition({ lat: props.infoWindow.position.geometry.coordinates[1], @@ -203,51 +244,73 @@ const InputMapGoogle: React.FunctionComponent; + } + + const mapId = getGoogleMapId(); + return ( + onPositionChange(e, 'mapClicked')} + clickableIcons={false} + onClick={onMapClick} onZoomChanged={onZoomChange} onBoundsChanged={onBoundsChanged} - clickableIcons={false} > {props.markers.map((markerData, index) => ( - onPositionChange(e, 'markerDragged') : undefined} - draggable={markerData.draggable} - icon={{ - url: markerData.icon.url, - size: new google.maps.Size(markerData.icon.size[0], markerData.icon.size[1]), - scaledSize: new google.maps.Size(markerData.icon.size[0], markerData.icon.size[1]) - }} - onClick={markerData.onClick} + markerData={markerData} + onDragEnd={onMarkerDragEnd} /> ))} - - ) : ( - + + ); +}; + +const InputMapGoogle: React.FunctionComponent = (props) => { + const { i18n } = useTranslation(); + // Set the google map config once, as it cannot be changed after it is + // loaded (for language change for example). see + // https://stackoverflow.com/questions/7065420/how-can-i-change-the-language-of-google-maps-on-the-run + const config = React.useMemo(() => getCurrentGoogleMapConfig(i18n.language), []); + + const onLoadError = React.useCallback((error: unknown) => { + const browserTechData = bowser.getParser(window.navigator.userAgent).parse(); + const message = + error instanceof Error ? error.message : typeof error === 'string' ? error : JSON.stringify(error); + logClientSideMessage( + `Google Maps API could not be loaded. Browser: ${JSON.stringify(browserTechData)}, error: ${message}` + ); + }, []); + + return ( + + + ); }; -export default withTranslation()(InputMapGoogle); +export default InputMapGoogle; diff --git a/packages/evolution-frontend/src/config/googleMaps.config.ts b/packages/evolution-frontend/src/config/googleMaps.config.ts index 83bd63b9d..16d7b983d 100644 --- a/packages/evolution-frontend/src/config/googleMaps.config.ts +++ b/packages/evolution-frontend/src/config/googleMaps.config.ts @@ -4,37 +4,50 @@ * This file is licensed under the MIT License. * License text available at https://opensource.org/licenses/MIT */ -import { useJsApiLoader } from '@react-google-maps/api'; -import InputLoading from '../components/inputs/InputLoading'; import projectConfig from 'chaire-lib-common/lib/config/shared/project.config'; -const googleMapConfigNew = { - id: 'google-map-script', - googleMapsApiKey: process.env.GOOGLE_API_KEY as string, - libraries: ['places' as const, 'geometry' as const] +export type GoogleMapApiConfig = { + apiKey: string; + libraries: string[]; + region: string; + language: string; }; -// Legacy google map configuration, still used in old PhotonOsmInputMap component -export default { +const baseConfig = (): Pick => ({ apiKey: process.env.GOOGLE_API_KEY as string, - LoadingContainer: InputLoading, - libraries: ['places', 'geometry'] -}; + libraries: ['places', 'geometry'], + region: projectConfig.region as string +}); // The google map configuration needs to be global as the loading of the API // takes place once for the whole survey and the configuration cannot change, // even between sections, otherwise it throws an exception. -let currentGoogleMapConfig: Parameters[0] | undefined = undefined; -export const getCurrentGoogleMapConfig = ( - language = projectConfig.defaultLocale -): Parameters[0] => { +let currentGoogleMapConfig: GoogleMapApiConfig | undefined = undefined; + +/** + * Returns the configuration to feed to `` from `@vis.gl/react-google-maps`. + * The result is memoized on the first call: the Google Maps JavaScript API can only be + * loaded once per page session, so subsequent calls (e.g. on language change) return + * the configuration that was used the first time. + * + * @param language Locale to load the API with. Defaults to `projectConfig.defaultLocale`. + */ +export const getCurrentGoogleMapConfig = (language = projectConfig.defaultLocale): GoogleMapApiConfig => { if (currentGoogleMapConfig) { return currentGoogleMapConfig; } currentGoogleMapConfig = { - region: projectConfig.region, - language: language, - ...googleMapConfigNew + ...baseConfig(), + language: language as string }; return currentGoogleMapConfig; }; + +/** + * Returns the Google Cloud Map ID configured via the `GOOGLE_MAP_ID` env var, or + * `undefined` when not set. Required to enable ``; when absent, + * the map widgets fall back to the legacy `` component. + * + * See https://developers.google.com/maps/documentation/get-map-id + */ +export const getGoogleMapId = (): string | undefined => process.env.GOOGLE_MAP_ID || undefined; diff --git a/packages/evolution-frontend/src/utils/dev/webpackCommon.ts b/packages/evolution-frontend/src/utils/dev/webpackCommon.ts index 0cbd6082f..688eacf7d 100644 --- a/packages/evolution-frontend/src/utils/dev/webpackCommon.ts +++ b/packages/evolution-frontend/src/utils/dev/webpackCommon.ts @@ -110,6 +110,7 @@ export const createCommonWebpackConfig = (params: WebpackGenerationConfigParams) APP_NAME: JSON.stringify('survey'), IS_TESTING: JSON.stringify(currentNodeEnv === 'test'), GOOGLE_API_KEY: JSON.stringify(process.env.GOOGLE_API_KEY), + GOOGLE_MAP_ID: JSON.stringify(process.env.GOOGLE_MAP_ID), ...stringifyEnvValues(params.extraEnvs) }, __CONFIG__: JSON.stringify({ diff --git a/packages/evolution-frontend/tsconfig.json b/packages/evolution-frontend/tsconfig.json index c09b43d82..db87a8c3f 100644 --- a/packages/evolution-frontend/tsconfig.json +++ b/packages/evolution-frontend/tsconfig.json @@ -5,6 +5,10 @@ "rootDir": "src", "isolatedModules": false, // Should be removed to inherit the true eventually "noImplicitAny": false, // Should be removed to inherit the true eventually + // Add google.maps to the inherited type list. @types/google.maps is + // pulled in transitively by @vis.gl/react-google-maps and provides the + // global `google.maps` namespace used by the map widgets. + "types": ["node", "jest", "geojson", "google.maps"] }, "include": [ "src/**/*" diff --git a/yarn.lock b/yarn.lock index fdb5ed3cb..9678e084d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1159,18 +1159,12 @@ "@shikijs/types" "^3.23.0" "@shikijs/vscode-textmate" "^10.0.2" -"@googlemaps/js-api-loader@1.16.8": - version "1.16.8" - resolved "https://registry.yarnpkg.com/@googlemaps/js-api-loader/-/js-api-loader-1.16.8.tgz#1595a2af80ca07e551fc961d921a2437d1cb3643" - integrity sha512-CROqqwfKotdO6EBjZO/gQGVTbeDps5V7Mt9+8+5Q+jTg5CRMi3Ii/L9PmV3USROrt2uWxtGzJHORmByxyo9pSQ== - -"@googlemaps/markerclusterer@2.5.3": - version "2.5.3" - resolved "https://registry.yarnpkg.com/@googlemaps/markerclusterer/-/markerclusterer-2.5.3.tgz#9f891ce7e8e161775f3a3e2c9f66956810284591" - integrity sha512-x7lX0R5yYOoiNectr10wLgCBasNcXFHiADIBdmn7jQllF2B5ENQw5XtZK+hIw4xnV0Df0xhN4LN98XqA5jaiOw== +"@googlemaps/js-api-loader@^2.0.2": + version "2.0.2" + resolved "https://registry.yarnpkg.com/@googlemaps/js-api-loader/-/js-api-loader-2.0.2.tgz#09d8b2029e71bfeb3951747b1ca951e5f8fcb14f" + integrity sha512-bKVuTqatS8Jven5aFqVB7rCHF1VFEzpzyi0ruzO0GUR+A7m9oMqMgtnmpANj7kMYEvvhty8Fk7TnJ1MKjWHu+Q== dependencies: - fast-deep-equal "^3.1.3" - supercluster "^8.0.1" + "@types/google.maps" "^3.53.1" "@grpc/grpc-js@^1.7.1": version "1.10.10" @@ -2579,28 +2573,6 @@ resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.1.tgz#eaee5900122c110a3dbcb728c0597014a2621774" integrity sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg== -"@react-google-maps/api@^2.20.6": - version "2.20.6" - resolved "https://registry.yarnpkg.com/@react-google-maps/api/-/api-2.20.6.tgz#8463a4db10dd2764b14da4bfe942a584693eb4b3" - integrity sha512-frxkSHWbd36ayyxrEVopSCDSgJUT1tVKXvQld2IyzU3UnDuqqNA3AZE4/fCdqQb2/zBQx3nrWnZB1wBXDcrjcw== - dependencies: - "@googlemaps/js-api-loader" "1.16.8" - "@googlemaps/markerclusterer" "2.5.3" - "@react-google-maps/infobox" "2.20.0" - "@react-google-maps/marker-clusterer" "2.20.0" - "@types/google.maps" "3.58.1" - invariant "2.2.4" - -"@react-google-maps/infobox@2.20.0": - version "2.20.0" - resolved "https://registry.yarnpkg.com/@react-google-maps/infobox/-/infobox-2.20.0.tgz#7c3dd1821c9f1e1e92570f37419b97f6f956c7ee" - integrity sha512-03PJHjohhaVLkX6+NHhlr8CIlvUxWaXhryqDjyaZ8iIqqix/nV8GFdz9O3m5OsjtxtNho09F/15j14yV0nuyLQ== - -"@react-google-maps/marker-clusterer@2.20.0": - version "2.20.0" - resolved "https://registry.yarnpkg.com/@react-google-maps/marker-clusterer/-/marker-clusterer-2.20.0.tgz#6b64177843a60c66e0ebaf85037a47ecd07007df" - integrity sha512-tieX9Va5w1yP88vMgfH1pHTacDQ9TgDTjox3tLlisKDXRQWdjw+QeVVghhf5XqqIxXHgPdcGwBvKY6UP+SIvLw== - "@shikijs/engine-oniguruma@^3.23.0": version "3.23.0" resolved "https://registry.yarnpkg.com/@shikijs/engine-oniguruma/-/engine-oniguruma-3.23.0.tgz#789421048d66ac1b33613169d6d18b9cc6e340ed" @@ -4840,10 +4812,10 @@ "@types/minimatch" "*" "@types/node" "*" -"@types/google.maps@3.58.1": - version "3.58.1" - resolved "https://registry.yarnpkg.com/@types/google.maps/-/google.maps-3.58.1.tgz#71ce3dec44de1452f56641d2c87c7dd8ea964b4d" - integrity sha512-X9QTSvGJ0nCfMzYOnaVs/k6/4L+7F5uCS+4iUmkLEls6J9S/Phv+m/i3mDeyc49ZBgwab3EFO1HEoBY7k98EGQ== +"@types/google.maps@^3.53.1", "@types/google.maps@^3.54.10": + version "3.64.1" + resolved "https://registry.yarnpkg.com/@types/google.maps/-/google.maps-3.64.1.tgz#95bd9b53e7598232f78b9132f2c41f8970ccd809" + integrity sha512-nEBoa6iDNipICtxJ5VlrOgPNZQ6ixIg5nuv8iryFj0Z/1NLgxyg3pQCVegPuCzGCyTQwQI/N3uZvLUysqAzaaw== "@types/hast@^3.0.0", "@types/hast@^3.0.4": version "3.0.4" @@ -5489,6 +5461,15 @@ resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz#538b1e103bf8d9864e7b85cc96fa8d6fb6c40777" integrity sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g== +"@vis.gl/react-google-maps@^1.8.3": + version "1.8.3" + resolved "https://registry.yarnpkg.com/@vis.gl/react-google-maps/-/react-google-maps-1.8.3.tgz#c6bc5c70881dfe9aca3b5ac2f399b168acf99e25" + integrity sha512-DW7nEuvOJ299DmdBnvGiUARrgS/+sTEO1iJgG9J8YaErZqLoq7S4TJ22f3EjJvR4dti4L4gft43JEK77nnKXDw== + dependencies: + "@googlemaps/js-api-loader" "^2.0.2" + "@types/google.maps" "^3.54.10" + fast-deep-equal "^3.1.3" + "@vis.gl/react-mapbox@8.0.4": version "8.0.4" resolved "https://registry.yarnpkg.com/@vis.gl/react-mapbox/-/react-mapbox-8.0.4.tgz#f87fc26fa89ccf62f39e04cea690a1faa0b23178" @@ -9270,13 +9251,6 @@ interpret@^3.1.1: resolved "https://registry.yarnpkg.com/interpret/-/interpret-3.1.1.tgz#5be0ceed67ca79c6c4bc5cf0d7ee843dcea110c4" integrity sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ== -invariant@2.2.4: - version "2.2.4" - resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6" - integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA== - dependencies: - loose-envify "^1.0.0" - ipaddr.js@1.9.1: version "1.9.1" resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3"