Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ EXPRESS_SESSION_SECRET_KEY = "<mySecretKey>"
# Google maps
GOOGLE_API_KEY = "<myGoogleKey>"
GOOGLE_API_KEY_DEV = "<myGoogleDevKey>"
# 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 = "<myGoogleMapId>"

# Passwordless login
MAGIC_LINK_SECRET_KEY = "<myVeryLongSecretKeyToEncryptTokenToSendToUser>"
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/evolution-frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -27,22 +27,22 @@ const mockedGeocode = geocodeSinglePoint as jest.MockedFunction<typeof geocodeSi
const userAttributes = {
id: 1,
username: 'foo',
preferences: { },
preferences: {},
serializedPermissions: [],
isAuthorized: () => true,
is_admin: false,
pages: [],
showUserInfo: true
}
};

const baseWidgetConfig = {
type: 'question' as const,
twoColumns: true,
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,
Expand All @@ -55,7 +55,7 @@ describe('Render InputMapPoint with various parameters', () => {
const { container } = render(
<InputMapPoint
id={'test'}
onValueChange={() => { /* nothing to do */}}
onValueChange={() => { /* nothing to do */ }}
widgetConfig={baseWidgetConfig}
value={undefined}
inputRef={React.createRef()}
Expand All @@ -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',
Expand All @@ -85,9 +85,9 @@ describe('Render InputMapPoint with various parameters', () => {
const { container } = render(
<InputMapPoint
id={'test'}
onValueChange={() => { /* 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}
Expand All @@ -96,7 +96,7 @@ describe('Render InputMapPoint with various parameters', () => {
);
expect(container).toMatchSnapshot();
});

});

describe('Test geocoding requests', () => {
Expand All @@ -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',
Expand All @@ -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(
<InputMapPoint
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()}
interview={interviewAttributes}
user={userAttributes}
path="foo.test"
/>
);

beforeEach(() => {
mockedGeocode.mockClear();
mockOnValueChange.mockClear();
});

test('Geocode single result', async () => {

render(<InputMapPoint
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()}
interview={interviewAttributes}
user={userAttributes}
path='foo.test'
/>);
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(<InputMapPoint
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()}
interview={interviewAttributes}
user={userAttributes}
path='foo.test'
/>);
// 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<GeoJSON.Point> | 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(<InputMapPoint
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()}
interview={interviewAttributes}
user={userAttributes}
path='foo.test'
/>);
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 } });
});

});
Original file line number Diff line number Diff line change
@@ -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 `<Map>` 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 `<Map>`.
*
* @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<google.maps.Polyline | null>(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]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return null;
};

/**
* Renders a `google.maps.Polygon` on the enclosing `<Map>`.
*
* @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<google.maps.Polygon | null>(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;
};
Loading
Loading