From e58feee9f97204e9e31e994d33ecfdef80ee1ec9 Mon Sep 17 00:00:00 2001 From: Lucas Cordeiro Date: Wed, 17 Nov 2021 17:40:41 -0300 Subject: [PATCH 01/15] Rename files --- react/components/{Map.js => Map.jsx} | 0 react/components/{ZoomControls.js => ZoomControls.tsx} | 0 react/constants/{index.js => index.ts} | 0 react/{modalStateContext.js => modalStateContext.tsx} | 0 react/utils/{StateUtils.js => StateUtils.ts} | 0 5 files changed, 0 insertions(+), 0 deletions(-) rename react/components/{Map.js => Map.jsx} (100%) rename react/components/{ZoomControls.js => ZoomControls.tsx} (100%) rename react/constants/{index.js => index.ts} (100%) rename react/{modalStateContext.js => modalStateContext.tsx} (100%) rename react/utils/{StateUtils.js => StateUtils.ts} (100%) diff --git a/react/components/Map.js b/react/components/Map.jsx similarity index 100% rename from react/components/Map.js rename to react/components/Map.jsx diff --git a/react/components/ZoomControls.js b/react/components/ZoomControls.tsx similarity index 100% rename from react/components/ZoomControls.js rename to react/components/ZoomControls.tsx diff --git a/react/constants/index.js b/react/constants/index.ts similarity index 100% rename from react/constants/index.js rename to react/constants/index.ts diff --git a/react/modalStateContext.js b/react/modalStateContext.tsx similarity index 100% rename from react/modalStateContext.js rename to react/modalStateContext.tsx diff --git a/react/utils/StateUtils.js b/react/utils/StateUtils.ts similarity index 100% rename from react/utils/StateUtils.js rename to react/utils/StateUtils.ts From 35771a4687cb722784af944ac0828ff0cafd64b7 Mon Sep 17 00:00:00 2001 From: Lucas Cordeiro Date: Wed, 17 Nov 2021 17:44:03 -0300 Subject: [PATCH 02/15] Add google maps types --- react/package.json | 1 + react/tsconfig.json | 3 ++- react/yarn.lock | 5 +++++ 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/react/package.json b/react/package.json index 7367d54b..c6dedb4c 100644 --- a/react/package.json +++ b/react/package.json @@ -43,6 +43,7 @@ "lib": "lib" }, "devDependencies": { + "@types/google.maps": "^3.46.1", "@types/node": "^16.11.6", "@types/prop-types": "^15.7.4", "@types/react": "^17.0.34", diff --git a/react/tsconfig.json b/react/tsconfig.json index 91d84ee6..cd40e480 100644 --- a/react/tsconfig.json +++ b/react/tsconfig.json @@ -20,7 +20,8 @@ "target": "es2017", "noUnusedLocals": false, "noUnusedParameters": false, - "allowJs": true + "allowJs": true, + "types": ["google.maps"] }, "include": ["./**/*.js", "./**/*.jsx", "./**/*.ts", "./**/*.tsx"] } diff --git a/react/yarn.lock b/react/yarn.lock index 2d8985fb..2ea01866 100644 --- a/react/yarn.lock +++ b/react/yarn.lock @@ -8,6 +8,11 @@ dependencies: regenerator-runtime "^0.12.0" +"@types/google.maps@^3.46.1": + version "3.46.1" + resolved "https://registry.yarnpkg.com/@types/google.maps/-/google.maps-3.46.1.tgz#a1a3bf99402022ea0039cc8c3230a1f9a216e46e" + integrity sha512-GAa5ZWYgXG50yLXybb7A824esGm/L0LKHS7qD0qkP0IA/Qp5r922P9tmYcbCkGEf3Zgf7Ukbp7l08/IGIJuQwQ== + "@types/node@^16.11.6": version "16.11.6" resolved "https://registry.yarnpkg.com/@types/node/-/node-16.11.6.tgz#6bef7a2a0ad684cf6e90fcfe31cecabd9ce0a3ae" From 5eb2186217987bd0991d91e692ede017dc32d3b9 Mon Sep 17 00:00:00 2001 From: Lucas Cordeiro Date: Wed, 17 Nov 2021 17:44:41 -0300 Subject: [PATCH 03/15] Fix typo on argument name --- react/ModalState.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/react/ModalState.js b/react/ModalState.js index 3357a588..c95eed2a 100644 --- a/react/ModalState.js +++ b/react/ModalState.js @@ -257,7 +257,7 @@ class ModalState extends Component { this.setSelectedPickupPoint({ pickupPoint: bestPickupOptions[previousIndex], - isSelectedBestPickupPoint: previousIndex < BEST_PICKUPS_AMOUNT, + isBestPickupPoint: previousIndex < BEST_PICKUPS_AMOUNT, }) } @@ -272,7 +272,7 @@ class ModalState extends Component { this.setSelectedPickupPoint({ pickupPoint: bestPickupOptions[nextIndex], - isSelectedBestPickupPoint: nextIndex < BEST_PICKUPS_AMOUNT, + isBestPickupPoint: nextIndex < BEST_PICKUPS_AMOUNT, }) } From 8ffc5f804ae3160c5e21dacfe216e027a558d01d Mon Sep 17 00:00:00 2001 From: Lucas Cordeiro Date: Wed, 17 Nov 2021 18:06:09 -0300 Subject: [PATCH 04/15] Add types for modal state context --- react/modalStateContext.tsx | 153 ++++++++++++++++++------------------ react/utils/StateUtils.ts | 39 ++++++--- 2 files changed, 103 insertions(+), 89 deletions(-) diff --git a/react/modalStateContext.tsx b/react/modalStateContext.tsx index b7b1d21a..e039ff85 100644 --- a/react/modalStateContext.tsx +++ b/react/modalStateContext.tsx @@ -1,80 +1,81 @@ -import React from 'react' +import type { ComponentType } from 'react' +import React, { useContext } from 'react' -export const ModalStateContext = React.createContext() +import type { + ActiveState, + SidebarActiveState, + GeolocationStatus, +} from './utils/StateUtils' -export function injectState(Component) { - return function StateInjectedComponent(props) { - return ( - - {({ - activeState, - activeSidebarState, - bestPickupOptions, - externalPickupPoints, - geolocationStatus, - hoverPickupPoint, - isSearching, - isSelectedBestPickupPoint, - searchPickupsInArea, - lastState, - lastSidebarState, - lastMapCenterLatLng, - logisticsInfo, - pickupOptions, - pickupPoints, - residentialAddress, - searchedAreaNoPickups, - selectNextPickupPoint, - selectPreviousPickupPoint, - selectedPickupPoint, - setGeolocationStatus, - setHoverPickupPoint, - setActiveState, - setActiveSidebarState, - setAskForGeolocation, - setSelectedPickupPoint, - setMapCenterLatLng, - setShouldSearchArea, - setShowOtherPickupPoints, - shouldSearchArea, - showOtherPickupPoints, - }) => ( - - )} - - ) +// TODO: we should replace the remaining `any` types with the actual types for +// the properties +/* eslint-disable @typescript-eslint/no-explicit-any */ +interface ModalState { + activeState: ActiveState + activeSidebarState: SidebarActiveState + bestPickupOptions: any[] + externalPickupPoints: any[] + geolocationStatus: GeolocationStatus + hoverPickupPoint: any + isSearching: boolean + isSelectedBestPickupPoint: boolean + searchPickupsInArea: (geoCoordinates: any, address: any) => void + lastState: ActiveState + lastSidebarState: SidebarActiveState + lastMapCenterLatLng: google.maps.LatLng | null + logisticsInfo: any[] | undefined + pickupOptions: any[] | undefined + pickupPoints: any[] | undefined + residentialAddress: any + searchedAreaNoPickups: boolean + selectNextPickupPoint: () => void + selectPreviousPickupPoint: () => void + selectedPickupPoint: any + setGeolocationStatus: GeolocationStatus + setHoverPickupPoint: (pickupPoint: any) => void + setActiveState: (state: ActiveState) => void + setActiveSidebarState: (sidebarState: SidebarActiveState) => void + setAskForGeolocation: (askForGeolocation: boolean) => void + setSelectedPickupPoint: (value: { + pickupPoint: any + isBestPickupPoint: boolean + }) => void + setMapCenterLatLng: (center: google.maps.LatLng) => void + setShouldSearchArea: (shouldSearch: boolean) => void + setShowOtherPickupPoints: (showOtherPickupPoints: boolean) => void + shouldSearchArea: boolean + showOtherPickupPoints: boolean +} +/* eslint-enable @typescript-eslint/no-explicit-any */ + +export const ModalStateContext = React.createContext( + undefined +) + +export const useModalState = () => { + const context = useContext(ModalStateContext) + + if (context === undefined) { + throw new Error('useModalState must be used inside ') + } + + return context +} + +export function injectState(Component: ComponentType) { + const StateInjectedComponent: React.FC = (props) => { + let context + + try { + context = useModalState() + } catch (e) { + throw new Error(`injectState(${Component.displayName}): ${e.message}`) + } + + return } + + StateInjectedComponent.displayName = `withModalState(${Component.displayName})` + + return StateInjectedComponent } diff --git a/react/utils/StateUtils.ts b/react/utils/StateUtils.ts index 8c856319..43a8c207 100644 --- a/react/utils/StateUtils.ts +++ b/react/utils/StateUtils.ts @@ -6,22 +6,34 @@ import { GEOLOCATION_SEARCHING, } from '../constants' -export function isDifferentGeoCoords(a, b) { +export type GeolocationStatus = + | 'prompt' + | 'searching' + | 'errorNotAllowed' + | 'errorNotFound' + | 'errorCouldNotGetLocation' + +export function isDifferentGeoCoords(a: [number, number], b: [number, number]) { return a[0] !== b[0] || a[1] !== b[1] } -export function isCurrentState(state, activeState) { +export function isCurrentState(state: ActiveState, activeState: ActiveState) { return state === activeState } -export function isCurrentStateFromAllStates(currentState, states) { +export function isCurrentStateFromAllStates( + currentState: ActiveState | SidebarActiveState, + states: { activeState: ActiveState; activeSidebarState: SidebarActiveState } +) { return ( currentState === states.activeState || currentState === states.activeSidebarState ) } -export const getInitialActiveState = props => { +export type ActiveState = 'sidebar' | 'geolocation_searching' | 'initial' + +export const getInitialActiveState = (props: any): ActiveState => { if (props.askForGeolocation) { return GEOLOCATION_SEARCHING } @@ -33,7 +45,11 @@ export const getInitialActiveState = props => { return INITIAL } -export const getInitialActiveSidebarState = props => { +export type SidebarActiveState = 'geolocation_searching' | 'details' | 'list' + +export const getInitialActiveSidebarState = ( + props: any +): SidebarActiveState => { if (props.askForGeolocation) { return GEOLOCATION_SEARCHING } @@ -45,12 +61,9 @@ export const getInitialActiveSidebarState = props => { return LIST } -export function getCleanId(id) { - return ( - id && - id - .replace(/[^\w\s]/gi, '') - .split(' ') - .join('-') - ) +export function getCleanId(id?: string) { + return id + ?.replace(/[^\w\s]/gi, '') + .split(' ') + .join('-') } From f50a4479f29c1e550e3dce2eb4f4f3604e963518 Mon Sep 17 00:00:00 2001 From: Lucas Cordeiro Date: Wed, 17 Nov 2021 18:22:08 -0300 Subject: [PATCH 05/15] Create components to instantiate google maps and markers declaratively This commit adds two components: `GoogleMap` and `GoogleMarker`. This was heavily inspired by the react-google-maps library, but without all the flexibility and with custom interactions on how the map inside pickup-points-modal works. These components will allow us to render the map declaratively instead of using refs and imperative code to handle the rendering of the markers and side-effects (such as re-centering the map after the user changes their address), in a much more maintainable fashion. --- react/components/GoogleMap.tsx | 365 ++++++++++++++++++++++++++++++ react/components/GoogleMarker.tsx | 158 +++++++++++++ react/components/ZoomControls.tsx | 79 +++++-- 3 files changed, 577 insertions(+), 25 deletions(-) create mode 100644 react/components/GoogleMap.tsx create mode 100644 react/components/GoogleMarker.tsx diff --git a/react/components/GoogleMap.tsx b/react/components/GoogleMap.tsx new file mode 100644 index 00000000..4a134fbe --- /dev/null +++ b/react/components/GoogleMap.tsx @@ -0,0 +1,365 @@ +import type { ReactNode, CSSProperties } from 'react' +import React, { + useImperativeHandle, + forwardRef, + useRef, + useMemo, + useCallback, + createContext, + useState, + useEffect, + useContext, +} from 'react' + +import { useModalState } from '../modalStateContext' +import { LIST } from '../constants' + +interface Context { + map: google.maps.Map | null + onZoomIn: () => void + onZoomOut: () => void +} + +const ctx = createContext(null) + +export const useMaps = () => { + const contextValue = useContext(ctx) + + if (contextValue == null) { + throw new Error('useMaps must be used inside GoogleMaps component') + } + + return contextValue +} + +interface Props { + /** + * Center of the map in the format of [longitude, latitude] + */ + center?: [number, number] + /** + * Wheter or not we are in a large screen (desktop). This is useful for the + * recentering logic to accomodate for the extra UI displayed on top of the map + */ + isLargeScreen?: boolean + /** + * + * Whether or not we are still waiting for the Google Maps SDK to load + */ + isLoading: boolean + /** + * Element to display while loading the Google Maps SDK + */ + loadingElement: ReactNode + /** + * Click handler for when the map itself is clicked + */ + onClick?: () => void + /** + * Reference point in map to use for when prompting to search another area + * besides the surroundings of the address geo coordinates + */ + referenceCenter?: [number, number] + /** + * Bounds of the map + * @see {@type google.maps.Map['fitBounds']} + */ + bounds?: google.maps.LatLngBounds | null +} + +const STANDARD_ZOOM = 14 + +/** + * Horizontal panning to accomodate the extra UI in large screns + */ +const PAN_LEFT_LAT = -160 +/** + * Vertical panning to accomodate the extra UI in large screns + */ +const PAN_LEFT_LNG = -30 + +const MAP_STYLES: CSSProperties = { + height: '100%', + width: '100%', + position: 'absolute', + top: 0, + zIndex: 0, +} + +/** + * Transforms the geoCoordinates of the API to a LatLng object from Google Maps + * SDK + * + * @param {[number, number]} geoCoordinates - Geocoordinates in [longitude, latitude] format + * @returns {google.maps.LatLng} LatLng object with the same position as `geoCoordinates` + */ +const geoCoordinatesToLatLng = (geoCoordinates: [number, number]) => { + const [lng, lat] = geoCoordinates + const location = new google.maps.LatLng(lat, lng) + + return location +} + +const isGeoCoordinatesEqual = ( + geoA: Array, + geoB: number[] +) => { + if (geoA.length !== geoB.length) { + return false + } + + const [lngA, latA] = geoA + const [lngB, latB] = geoB + + return lngA === lngB && latA === latB +} + +const useMapEvent = ( + map: google.maps.Map | null, + eventName: string, + handler: () => void +) => { + const handlerRef = useRef(handler) + + useEffect(() => { + handlerRef.current = handler + }) + + useEffect(() => { + if (map == null) { + return + } + + const event = google.maps.event.addListener(map, eventName, () => { + handlerRef.current() + }) + + return () => { + google.maps.event.removeListener(event) + } + }, [eventName, map]) +} + +interface GoogleMapRefObject { + getBounds(): google.maps.LatLngBounds | undefined +} + +export const GoogleMap = forwardRef( + function GoogleMap( + { + children, + center = [], + isLargeScreen = false, + isLoading, + loadingElement, + onClick, + referenceCenter, + bounds, + }, + ref + ) { + const { + setMapCenterLatLng, + setShouldSearchArea, + setActiveState, + setActiveSidebarState, + activeState, + } = useModalState() + + const [map, setMap] = useState(null) + + const referenceCenterRef = useRef(referenceCenter) + + useEffect(() => { + if (referenceCenter === referenceCenterRef.current) { + return + } + + referenceCenterRef.current = referenceCenter + }, [referenceCenter]) + + const toggleSearchArea = useCallback(() => { + const currentCenter = map?.getCenter() + + if (currentCenter == null || referenceCenterRef.current == null) { + return + } + + const [referenceCenterLng, referenceCenterLat] = + referenceCenterRef.current + + const differenceLat = (referenceCenterLat - currentCenter.lat()) * 1000 + const differenceLng = (referenceCenterLng - currentCenter.lng()) * 1000 + + const DISTANCE = 50 + + const outerLat = differenceLat > DISTANCE || differenceLat < -DISTANCE + const outerLng = differenceLng > DISTANCE || differenceLng < -DISTANCE + + setShouldSearchArea(outerLat || outerLng) + setMapCenterLatLng(currentCenter) + }, [map, setMapCenterLatLng, setShouldSearchArea]) + + const mapMounted = (node: HTMLDivElement | null) => { + if (node == null || map !== null) { + return + } + + const googleMap = new google.maps.Map(node, { + zoom: STANDARD_ZOOM, + disableDefaultUI: true, + mapTypeControl: false, + fullscreenControl: false, + streetViewControl: false, + clickableIcons: false, + styles: [ + { + featureType: 'poi', + stylers: [{ visibility: 'off' }], + }, + { + featureType: 'transit', + elementType: 'labels.icon', + stylers: [{ visibility: 'off' }], + }, + ], + center: + center.length === 2 ? geoCoordinatesToLatLng(center) : undefined, + }) + + if (bounds) { + googleMap.fitBounds(bounds) + } + + if (center.length === 2) { + toggleSearchArea() + + if (isLargeScreen) { + googleMap.panBy(PAN_LEFT_LAT, PAN_LEFT_LNG) + } + } + + setMap(googleMap) + } + + const prevBoundsRef = useRef( + undefined + ) + + useEffect(() => { + if ( + map == null || + isGeoCoordinatesEqual( + [map.getCenter()?.lng(), map.getCenter()?.lat()], + center + ) + ) { + return + } + + if (center.length !== 2) { + return + } + + map.setCenter(geoCoordinatesToLatLng(center)) + + if (isLargeScreen) { + map.panBy(PAN_LEFT_LAT, PAN_LEFT_LNG) + } + }, [map, center, isLargeScreen]) + + useEffect(() => { + if ( + map == null || + !bounds || + bounds.equals(prevBoundsRef.current ?? null) + ) { + return + } + + map.fitBounds(bounds) + + if (isLargeScreen) { + map.panBy(PAN_LEFT_LAT, PAN_LEFT_LNG) + } + + prevBoundsRef.current = bounds + }, [map, bounds, isLargeScreen]) + + useMapEvent(map, 'dragend', () => { + toggleSearchArea() + }) + + useMapEvent(map, 'click', () => { + setActiveSidebarState(LIST) + onClick?.() + }) + + useMapEvent(map, 'zoom_changed', () => { + setActiveState(activeState) + }) + + useMapEvent(map, 'dragstart', () => { + setActiveState(activeState) + }) + + useEffect(() => { + if (map == null) { + return + } + + // TODO: can we move this `querySelector` call to use React refs instead? + map.controls[google.maps.ControlPosition.RIGHT_BOTTOM].push( + document.querySelector('.zoom-control') + ) + }, [map]) + + const handleZoomIn = useCallback(() => { + if (map == null) { + return + } + + const currentZoom = map.getZoom() ?? STANDARD_ZOOM + + map.setZoom(currentZoom + 1) + }, [map]) + + const handleZoomOut = useCallback(() => { + if (map == null) { + return + } + + const currentZoom = map.getZoom() ?? STANDARD_ZOOM + + map.setZoom(currentZoom - 1) + }, [map]) + + useImperativeHandle( + ref, + () => ({ + getBounds: () => map?.getBounds(), + }), + [map] + ) + + const contextValue = useMemo( + () => ({ + map, + onZoomIn: handleZoomIn, + onZoomOut: handleZoomOut, + }), + [handleZoomIn, handleZoomOut, map] + ) + + if (isLoading) { + return <>{loadingElement} + } + + return ( + +
+ {children} + + ) + } +) diff --git a/react/components/GoogleMarker.tsx b/react/components/GoogleMarker.tsx new file mode 100644 index 00000000..7387aec8 --- /dev/null +++ b/react/components/GoogleMarker.tsx @@ -0,0 +1,158 @@ +import type React from 'react' +import { useRef, useEffect, useState } from 'react' + +import { useMaps } from './GoogleMap' + +type MarkerIcon = undefined | null | string | google.maps.Icon + +interface Props { + /** + * Position of the marker in the map in the format of [longitude, latitude] + */ + position?: [number, number] + /** + * Whether or not the marker is draggable + */ + draggable?: boolean + /** + * Relative position of the marker based on other markers in the map + * @see {@type google.maps.MarkerOptions['zIndex']} + */ + zIndex?: number + /** + * Icon of the marker + * @see {@type google.maps.MarkerOptions['icon']} + */ + icon?: string | google.maps.Icon + onClick?: () => void + onMouseOver?: () => void + onMouseOut?: () => void +} + +const isSameIcon = (iconA: MarkerIcon, iconB: MarkerIcon) => { + if (iconA == null) { + if (iconB == null) { + return true + } + + return false + } + + if (iconB == null) { + return false + } + + if (typeof iconA === 'string') { + if (typeof iconB !== 'string') { + return false + } + + return iconA === iconB + } + + if (typeof iconB === 'string') { + return false + } + + if (iconA.url !== iconB.url) { + return false + } + + if (!iconA.size?.equals(iconB.size ?? null)) { + return false + } + + if (!iconA.scaledSize?.equals(iconB.scaledSize ?? null)) { + return false + } + + return true +} + +const useMarkerEvent = ( + marker: google.maps.Marker, + eventName: string, + handler?: () => void +) => { + const handlerRef = useRef(handler) + + useEffect(() => { + handlerRef.current = handler + }, [handler]) + + useEffect(() => { + const eventListener = google.maps.event.addListener( + marker, + eventName, + () => { + handlerRef.current?.() + } + ) + + return () => { + google.maps.event.removeListener(eventListener) + } + }, [marker, eventName]) +} + +export const GoogleMarker: React.VFC = ({ + position, + draggable, + zIndex, + icon, + onClick, + onMouseOver, + onMouseOut, +}) => { + const [lng, lat] = position ?? [0, 0] + const { map } = useMaps() + const [marker] = useState( + () => + new google.maps.Marker({ + icon, + zIndex, + draggable, + position: new google.maps.LatLng(lat, lng), + }) + ) + + useEffect(() => { + if (map == null) { + return + } + + marker.setMap(map) + + return () => { + marker.setMap(null) + } + }, [map, marker]) + + useEffect(() => { + const currentPosition = marker.getPosition() + + if ( + currentPosition == null || + currentPosition.lat() !== lat || + currentPosition.lng() !== lng + ) { + marker.setPosition(new google.maps.LatLng(lat, lng)) + } + }, [marker, lat, lng]) + + useEffect(() => { + if (icon != null && !isSameIcon(icon, marker.getIcon() as MarkerIcon)) { + marker.setIcon(icon) + } + + if (zIndex != null && zIndex !== marker.getZIndex()) { + marker.setZIndex(zIndex) + } + }, [marker, icon, zIndex]) + + useMarkerEvent(marker, 'click', onClick) + useMarkerEvent(marker, 'mouseover', onMouseOver) + useMarkerEvent(marker, 'mouseout', onMouseOut) + + return null +} diff --git a/react/components/ZoomControls.tsx b/react/components/ZoomControls.tsx index f974173d..f26b6ff0 100644 --- a/react/components/ZoomControls.tsx +++ b/react/components/ZoomControls.tsx @@ -1,38 +1,67 @@ -import React, { PureComponent } from 'react' +import React, { useState, useEffect } from 'react' +import ReactDOM from 'react-dom' import PropTypes from 'prop-types' + import PlusIcon from '../assets/icons/plus_icon.svg' import MinusIcon from '../assets/icons/minus_icon.svg' import styles from './ZoomControls.css' import { SIDEBAR } from '../constants' -import { injectState } from '../modalStateContext' - -class ZoomControls extends PureComponent { - render() { - const { shouldShow, activeState, isLargeScreen } = this.props - - return ( -
- -
- -
- ) +import { useModalState } from '../modalStateContext' +import { useMaps } from './GoogleMap' + +interface Props { + shouldShow?: boolean + isLargeScreen?: boolean +} + +const ZoomControls: React.FC = ({ + shouldShow = false, + isLargeScreen = false, +}) => { + const { activeState } = useModalState() + const { onZoomIn, onZoomOut } = useMaps() + + const [root, setRoot] = useState(null) + + useEffect(() => { + setRoot(document.getElementById('controls-wrapper')) + }, []) + + if (root == null) { + return null } + + return ReactDOM.createPortal( +
+ +
+ +
, + root + ) } ZoomControls.propTypes = { shouldShow: PropTypes.bool, isLargeScreen: PropTypes.bool, - activeState: PropTypes.string, } -export default injectState(ZoomControls) +export default ZoomControls From c026477cbfc1efd636c57f9ba63eb4ff060005fc Mon Sep 17 00:00:00 2001 From: Lucas Cordeiro Date: Wed, 17 Nov 2021 18:27:45 -0300 Subject: [PATCH 06/15] Use GoogleMap and GoogleMarker components in Map --- react/PickupPointsModal.js | 50 +- react/components/Map.jsx | 901 +++++++++---------------------------- 2 files changed, 253 insertions(+), 698 deletions(-) diff --git a/react/PickupPointsModal.js b/react/PickupPointsModal.js index 5cc33cd5..7dc523d4 100644 --- a/react/PickupPointsModal.js +++ b/react/PickupPointsModal.js @@ -1,7 +1,10 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' import debounce from 'lodash/debounce' -import Map from './components/Map' +import { injectIntl, intlShape } from 'react-intl' +import { helpers } from 'vtex.address-form' + +import Map from './components/Map.jsx' import CloseButton from './components/CloseButton' import styles from './index.css' import ZoomControls from './components/ZoomControls' @@ -10,14 +13,16 @@ import ModalState from './ModalState' import Geolocation from './Geolocation' import SearchArea from './components/SearchArea' import SearchOverlay from './assets/components/SearchOverlay' -import { injectIntl, intlShape } from 'react-intl' import { withGoogleMaps } from './containers/withGoogleMaps' import { translate } from './utils/i18nUtils' import { newAddress } from './utils/newAddress' import { HIDE_MAP, SHOW_MAP } from './constants' import { getPickupOptionGeolocations } from './utils/pickupUtils' -import { helpers } from 'vtex.address-form' -import { closePickupPointsModalEvent, openPickupPointsModalEvent, searchPickupAddressByGeolocationEvent } from './utils/metrics' +import { + closePickupPointsModalEvent, + openPickupPointsModalEvent, + searchPickupAddressByGeolocationEvent, +} from './utils/metrics' const { validateField, addValidation } = helpers const NULL_VALUE = { @@ -48,7 +53,8 @@ class PickupPointsModal extends Component { } componentDidMount() { - const style = document.body.style + const { style } = document.body + style.overflow = 'hidden' style.position = 'fixed' style.width = '100%' @@ -83,6 +89,7 @@ class PickupPointsModal extends Component { // On mobile browsers trigger the resize event when keyboard is opened // even though the screen size itself is the same const isWidthEqual = this.state.innerWidth === window.innerWidth + if (!this.state.isMounted || isWidthEqual) return this.setState({ isLargeScreen: window.innerWidth > 1023, @@ -91,7 +98,7 @@ class PickupPointsModal extends Component { }) }, 200) - updateLocationTab = mapStatus => this.setState({ mapStatus }) + updateLocationTab = (mapStatus) => this.setState({ mapStatus }) activatePickupDetails = () => this.setState({ @@ -99,7 +106,7 @@ class PickupPointsModal extends Component { mapStatus: HIDE_MAP, }) - getPostalCodeValue = address => { + getPostalCodeValue = (address) => { // TODO move this to Address Form if ( address && @@ -119,7 +126,7 @@ class PickupPointsModal extends Component { return address.postalCode && address.postalCode.value } - getValidPostalCode = address => { + getValidPostalCode = (address) => { if (address.postalCode) { const postalCodevalue = this.getPostalCodeValue(address) @@ -149,10 +156,11 @@ class PickupPointsModal extends Component { visited: null, } } + return NULL_VALUE } - handleAddressChange = address => { + handleAddressChange = (address) => { const { searchAddress } = this.props const addressValidated = { @@ -180,7 +188,8 @@ class PickupPointsModal extends Component { } handleCloseModal = () => { - const style = document.body.style + const { style } = document.body + style.overflow = 'auto' style.position = '' if (this.state.isLoadingGeolocation) { @@ -191,6 +200,7 @@ class PickupPointsModal extends Component { elapsedTime, }) } + this.props.closePickupPointsModal() } @@ -265,25 +275,23 @@ class PickupPointsModal extends Component { pickupOptions={pickupOptions} salesChannel={salesChannel} orderFormId={orderFormId} - selectedPickupPoint={selectedPickupPoint}> + selectedPickupPoint={selectedPickupPoint} + > + rules={rules} + > - +
{shouldUseMaps && ( + > + + )} pickup.address.geoCoordinates - ) - ) - const prevExternalPickupPoints = - externalPickupPoints && - getPickupGeolocationString( - externalPickupPoints.map(pickup => pickup.address.geoCoordinates) - ) - - const rulesChanged = prevProps.rules.country !== rules.country - const loadingChanged = prevProps.isLoadingGoogle !== isLoadingGoogle - const screenSizeChanged = prevProps.isLargeScreen !== isLargeScreen - const addressChanged = - prevProps.address.geoCoordinates.value !== address.geoCoordinates.value - const pickupGeolocationsChanged = thisPickupOptions !== prevPickupOptions - const externalPickupPointsChanged = - thisExternalPickupPoints !== prevExternalPickupPoints - const selectedPickupPointChanged = - prevProps.selectedPickupPoint !== selectedPickupPoint - const pickupPointChanged = - prevProps.pickupPoint && - pickupPoint && - prevProps.pickupPoint.id !== pickupPoint.id - const hoverPickupPointChanged = - prevProps.hoverPickupPoint !== hoverPickupPoint - - return ( - rulesChanged || - loadingChanged || - screenSizeChanged || - addressChanged || - pickupPointChanged || - pickupGeolocationsChanged || - selectedPickupPointChanged || - externalPickupPointsChanged || - hoverPickupPointChanged - ) - } - componentWillUnmount() { - this.setState({ isMounted: false }) - this.removeListeners() + this.mapRef = React.createRef() } componentDidUpdate(prevProps) { - const { - googleMaps, - pickupPoints, - externalPickupPoints, - selectedPickupPoint, - hoverPickupPoint, - } = this.props - - const prevHoverPickupPoint = prevProps.hoverPickupPoint - - if (hoverPickupPoint !== prevHoverPickupPoint) { - this.setSelectedPickupPoint(hoverPickupPoint) - } - - const prevSelectedPickupPoint = prevProps.selectedPickupPoint - - this.center = this.getLocation(this.props.address.geoCoordinates.value) - - const thisPickupOptions = - prevProps.pickupPoints && - getPickupGeolocationString( - getPickupPointGeolocations(prevProps.pickupPoints) - ) - const prevPickupOptions = - pickupPoints && - getPickupGeolocationString(getPickupPointGeolocations(pickupPoints)) - - const thisExternalPickupPoints = - prevProps.externalPickupPoints && - getPickupGeolocationString( - prevProps.externalPickupPoints.map( - pickup => pickup.address.geoCoordinates - ) - ) - const prevExternalPickupPoints = - externalPickupPoints && - getPickupGeolocationString( - externalPickupPoints.map(pickup => pickup.address.geoCoordinates) - ) + const { selectedPickupPoint } = this.props - const nextAddressCoords = - this.props.address && - this.props.address.geoCoordinates && - this.props.address.geoCoordinates.value - const thisAddressCoords = prevProps.address.geoCoordinates.value - const markerObj = - this.markers && - this.markers.find( - item => - this.props.selectedPickupPoint && - item.pickupPoint === this.props.selectedPickupPoint.id - ) + const nextAddressCoords = this.props.address.geoCoordinates.value if ( nextAddressCoords && - this.isDifferentGeoCoords(nextAddressCoords, thisAddressCoords) && - googleMaps - ) { - this.recenterMap(this.getLocation(nextAddressCoords)) - if (this.props.pickupOptions.length === 0) { - this.addressMarker && - this.addressMarker.setPosition(this.getLocation(nextAddressCoords)) - return - } - this.resetMarkers(this.getLocation(nextAddressCoords)) - markerObj && - this.setIcon({ - marker: markerObj.marker, - width: BIG_MARKER_WIDTH, - height: BIG_MARKER_HEIGHT, - }) - } - - if (prevPickupOptions.length > 0 && this.props.pickupOptions.length === 0) { - this.searchMarkers.forEach(markerObj => { - markerObj.marker.setMap(null) - googleMaps.event.removeListener(markerObj.markerClickListener) - googleMaps.event.removeListener(markerObj.markerHoverListener) - googleMaps.event.removeListener(markerObj.markerHoverOutListener) - }) - this.searchMarkers = [] - this.markers.forEach(markerObj => { - markerObj.marker.setMap(null) - googleMaps.event.removeListener(markerObj.markerClickListener) - googleMaps.event.removeListener(markerObj.markerHoverListener) - googleMaps.event.removeListener(markerObj.markerHoverOutListener) - }) - this.markers = [] - return - } - - if ( - thisPickupOptions !== prevPickupOptions || - selectedPickupPoint !== prevSelectedPickupPoint - ) { - this.resetMarkers() - this.createNewMarkers() - this.setSelectedPickupPoint() - } - - if (thisExternalPickupPoints !== prevExternalPickupPoints) { - this.searchMarkers.forEach(markerObj => { - markerObj.marker.setMap(null) - googleMaps.event.removeListener(markerObj.markerClickListener) - googleMaps.event.removeListener(markerObj.markerHoverListener) - googleMaps.event.removeListener(markerObj.markerHoverOutListener) - }) - this.searchMarkers = [] - this.createNewMarkers() - } - - const bounds = this.map.getBounds() - - const isInBounds = - markerObj && bounds && bounds.contains(markerObj.marker.getPosition()) - - if ( - markerObj && - selectedPickupPoint !== prevProps.selectedPickupPoint && - !isInBounds + isDifferentGeoCoords( + prevProps.address.geoCoordinates.value, + nextAddressCoords + ) ) { - const geoCoordinates = selectedPickupPoint.pickupStoreInfo - ? selectedPickupPoint.pickupStoreInfo.address.geoCoordinates - : selectedPickupPoint.address.geoCoordinates - this.recenterMap(this.getLocation(geoCoordinates)) - this.resetMarkers() - this.setIcon({ - marker: markerObj.marker, - width: BIG_MARKER_WIDTH, - height: BIG_MARKER_HEIGHT, + this.setState({ + mapCenter: nextAddressCoords, }) - return } - } - mapMounted = mapElement => { - if (!mapElement) { - this.map = null - this.marker = null - return - } + const bounds = this.mapRef.current.getBounds() const geoCoordinates = - this.props.address.geoCoordinates.value.length > 0 - ? this.props.address.geoCoordinates.value - : this.props.activePickupPoint - ? this.props.activePickupPoint.pickupStoreInfo.address.geoCoordinates - : [] + selectedPickupPoint?.pickupStoreInfo?.address?.geoCoordinates ?? + selectedPickupPoint?.address?.geoCoordinates ?? + [] - this.createMap(mapElement, geoCoordinates) + const isInBounds = bounds?.contains(this.getLocation(geoCoordinates)) - if (geoCoordinates.length > 0) { - this.recenterMap(this.getLocation(geoCoordinates)) + if (selectedPickupPoint !== prevProps.selectedPickupPoint && !isInBounds) { + this.setState({ + mapCenter: geoCoordinates, + }) } - - this.createNewMarkers() - } - - toggleSearchArea = () => { - const newCenter = this.map && this.map.getCenter() - - if (!newCenter || !this.center) return - - const differenceLat = (this.center.lat() - newCenter.lat()) * 1000 - const differenceLng = (this.center.lng() - newCenter.lng()) * 1000 - - const DISTANCE = 50 - - const outerLat = differenceLat > DISTANCE || differenceLat < -DISTANCE - const outerLng = differenceLng > DISTANCE || differenceLng < -DISTANCE - - this.props.setShouldSearchArea(outerLat || outerLng) - this.props.setMapCenterLatLng(newCenter) } - createMap = mapElement => { - const { googleMaps, setActiveSidebarState } = this.props - - this._mapElement = mapElement - - const mapOptions = { - zoom: 14, - disableDefaultUI: true, - mapTypeControl: false, - fullscreenControl: false, - streetViewControl: false, - color: '#00ff00', - clickableIcons: false, - styles: [ - { - featureType: 'poi', - stylers: [{ visibility: 'off' }], - }, - { - featureType: 'transit', - elementType: 'labels.icon', - stylers: [{ visibility: 'off' }], - }, - ], - } - - this.map = new googleMaps.Map(this._mapElement, mapOptions) - - this.markers = [] - - this.searchMarkers = [] - - /* eslint-disable new-cap */ - this.centerChanged = new googleMaps.event.addListener( - this.map, - 'dragend', - () => { - this.toggleSearchArea() - } - ) - - const zoomIn = document.querySelector('.pkpmodal-zoom-in') - const zoomOut = document.querySelector('.pkpmodal-zoom-out') - - this.mapClickEvent = googleMaps.event.addListener(this.map, 'click', () => { - setActiveSidebarState(LIST) - this.resetMarkers() - }) - - this.mapPanEvent = googleMaps.event.addListener( - this.map, - 'zoom_changed', - () => { - this.props.setActiveState(this.props.activeState) - } - ) - this.mapPanEvent = googleMaps.event.addListener( - this.map, - 'dragstart', - () => { - this.props.setActiveState(this.props.activeState) - } - ) - - zoomIn.onclick = () => this.map.setZoom(this.map.getZoom() + 1) - zoomOut.onclick = () => this.map.setZoom(this.map.getZoom() - 1) - - this.map.controls[googleMaps.ControlPosition.RIGHT_BOTTOM].push( - document.querySelector('.zoom-control') - ) - } + getLocation = (geoCoordinates) => { + if (!this.props.googleMaps || !geoCoordinates) return + const [lng, lat] = geoCoordinates + const location = new this.props.googleMaps.LatLng(lat, lng) - removeListeners = () => { - const { googleMaps } = this.props - googleMaps.event.removeListener(this.mapClickEvent) - this.searchMarkers.forEach(markerObj => { - googleMaps.event.removeListener(markerObj.markerClickListener) - googleMaps.event.removeListener(markerObj.markerHoverListener) - googleMaps.event.removeListener(markerObj.markerHoverOutListener) - }) - this.markers.forEach(markerObj => { - googleMaps.event.removeListener(markerObj.markerClickListener) - googleMaps.event.removeListener(markerObj.markerHoverListener) - googleMaps.event.removeListener(markerObj.markerHoverOutListener) - }) + return location } - createNewMarkers = (shouldResetBounds = true) => { + render() { const { + googleMaps, + selectedPickupPoint, address, bestPickupOptions, externalPickupPoints, - googleMaps, isLargeScreen, pickupPoints, - selectedPickupPoint, + isLoadingGoogle, + loadingElement, + children, + changeActivePickupDetails, + setSelectedPickupPoint, + updateLocationTab, + setShouldSearchArea, } = this.props - const filteredExternalPickupPoints = - externalPickupPoints && - externalPickupPoints.filter( - pickup => - pickupPoints && - pickupPoints.length > 0 && - !pickupPoints.some(p => p.id.includes(pickup.id)) - ) - - const externalLocations = - filteredExternalPickupPoints && - filteredExternalPickupPoints - .map(pickup => this.getLocation(pickup.address.geoCoordinates)) - .filter(item => item) - - const hasAddressCoords = - address && address.geoCoordinates.value.length !== 0 + const { hoveringIds, mapCenter } = this.state - const hasPickupPoints = bestPickupOptions.length > 0 + const filteredExternalPickupPoints = externalPickupPoints?.filter( + (pickup) => + pickupPoints && + pickupPoints.length > 0 && + !pickupPoints.some((p) => p.id.includes(pickup.id)) + ) - if (!this.map) return - this.bounds = null + const externalLocations = filteredExternalPickupPoints + ?.map((pickup) => pickup.address.geoCoordinates) + .filter((item) => item) - if ((hasAddressCoords || hasPickupPoints) && shouldResetBounds) { - this.bounds = new googleMaps.LatLngBounds() - } + const hasAddressCoords = address?.geoCoordinates.value.length !== 0 - const addressLocation = hasAddressCoords - ? this.getLocation(address.geoCoordinates.value) - : null - const bestPickupLocation = - hasPickupPoints && - this.getLocation( - bestPickupOptions[0].pickupStoreInfo.address.geoCoordinates - ) + const bounds = hasAddressCoords ? new googleMaps.LatLngBounds() : null - const mapCenterLocation = addressLocation || bestPickupLocation + if (bounds != null) { + bounds.extend(this.getLocation(mapCenter)) - if ( - this.addressMarker && - bestPickupOptions.length > 0 && - !bestPickupOptions.some(pickup => !!pickup.pickupDistance) - ) { - this.addressMarker.setMap(null) - } + bestPickupOptions?.forEach((pickupPoint) => { + const location = this.getLocation( + pickupPoint.pickupStoreInfo.address.geoCoordinates + ) - if ( - !this.addressMarker && - (hasAddressCoords || bestPickupLocation) && - bestPickupOptions.length > 0 && - bestPickupOptions.some(pickup => !!pickup.pickupDistance) - ) { - const markerOptions = { - position: mapCenterLocation, - draggable: false, - zIndex: 1, - map: this.map, - icon: personPin, - } - - this.map.setCenter(mapCenterLocation) - this.recenterMap(mapCenterLocation) - - if (shouldResetBounds) { - this.bounds.extend(mapCenterLocation) - } - - this.addressMarker = new googleMaps.Marker(markerOptions) - } else if (hasAddressCoords && shouldResetBounds) { - this.bounds.extend(mapCenterLocation) + bounds.extend(location) + }) } - externalLocations && - externalLocations.forEach((location, index) => { - const isScaledMarker = - selectedPickupPoint && - selectedPickupPoint.id === filteredExternalPickupPoints[index].id - const markerOptions = { - position: location, - draggable: false, - zIndex: 2, - map: this.map, - icon: { - url: searchMarkerIcon, - size: isScaledMarker - ? new googleMaps.Size(BIG_MARKER_WIDTH, BIG_MARKER_HEIGHT) - : new googleMaps.Size(MARKER_WIDTH, MARKER_HEIGHT), - scaledSize: isScaledMarker - ? new googleMaps.Size(BIG_MARKER_WIDTH, BIG_MARKER_HEIGHT) - : new googleMaps.Size(MARKER_WIDTH, MARKER_HEIGHT), - }, - } - - const marker = new googleMaps.Marker(markerOptions) - - this.setupListeners({ - markersList: this.searchMarkers, - marker, - pickupPointsList: filteredExternalPickupPoints, - location, - index, - }) - }) + return ( + { + this.setState({ + hoveringIds: new Set(), + }) + setSelectedPickupPoint({}) + }} + referenceCenter={address.geoCoordinates.value} + bounds={bounds} + ref={this.mapRef} + > + {children} + + {externalLocations.map((location, index) => { + const pickupPoint = filteredExternalPickupPoints[index] + const pickupId = pickupPoint.id - bestPickupOptions && - bestPickupOptions - .filter(pickupPoint => { - return !this.markers.some( - markerObj => markerObj.pickupPoint === pickupPoint.id - ) - }) - .forEach((pickupPoint, index) => { - const location = this.getLocation( - pickupPoint.pickupStoreInfo.address.geoCoordinates + const isScaledMarker = + selectedPickupPoint?.id === pickupId || hoveringIds.has(pickupId) + + return ( + { + pickupPointSelectionEvent({ + selectionMethod: SELECTION_METHOD_MAP, + }) + changeActivePickupDetails({ + pickupPoint, + }) + setSelectedPickupPoint({ + pickupPoint, + isBestPickupPoint: false, + }) + updateLocationTab(HIDE_MAP) + setShouldSearchArea(false) + }} + onMouseOver={() => { + this.setState((prevState) => { + return { + ...prevState, + hoveringIds: new Set(prevState.hoveringIds.values()).add( + pickupId + ), + } + }) + }} + onMouseOut={() => { + this.setState((prevState) => { + const updatedSet = new Set(prevState.hoveringIds.values()) + + updatedSet.delete(pickupId) + + return { + ...prevState, + hoveringIds: updatedSet, + } + }) + }} + icon={{ + url: + selectedPickupPoint?.id === pickupId + ? searchingMarkerIcon + : searchMarkerIcon, + size: isScaledMarker + ? new googleMaps.Size(BIG_MARKER_WIDTH, BIG_MARKER_HEIGHT) + : new googleMaps.Size(MARKER_WIDTH, MARKER_HEIGHT), + scaledSize: isScaledMarker + ? new googleMaps.Size(BIG_MARKER_WIDTH, BIG_MARKER_HEIGHT) + : new googleMaps.Size(MARKER_WIDTH, MARKER_HEIGHT), + }} + /> ) + })} + {bestPickupOptions.map((pickupPoint, index) => { + const pickupId = pickupPoint.id + const location = pickupPoint.pickupStoreInfo.address.geoCoordinates + const markerIconImage = index < BEST_PICKUPS_AMOUNT && - bestPickupOptions.length > BEST_PICKUPS_AMOUNT && - this.markers.length < BEST_PICKUPS_AMOUNT + bestPickupOptions.length > BEST_PICKUPS_AMOUNT ? bestMarkerIcon : markerIcon - const isScaledMarker = - selectedPickupPoint && selectedPickupPoint.id === pickupPoint.id - - const markerOptions = { - position: location, - draggable: false, - map: this.map, - zIndex: index < BEST_PICKUPS_AMOUNT ? FORTH_ZINDEX : THIRD_ZINDEX, - icon: { - url: markerIconImage, - size: isScaledMarker - ? new googleMaps.Size(BIG_MARKER_WIDTH, BIG_MARKER_HEIGHT) - : new googleMaps.Size(MARKER_WIDTH, MARKER_HEIGHT), - scaledSize: isScaledMarker - ? new googleMaps.Size(BIG_MARKER_WIDTH, BIG_MARKER_HEIGHT) - : new googleMaps.Size(MARKER_WIDTH, MARKER_HEIGHT), - }, - } - - if (this.addressMarker && hasAddressCoords && shouldResetBounds) { - this.bounds.extend(location) - if (this.map.getZoom() < STANDARD_ZOOM) { - this.setZoom(STANDARD_ZOOM) - isLargeScreen && this.map.panBy(PAN_LEFT_LAT, PAN_LEFT_LNG) - } - } - - const marker = new googleMaps.Marker(markerOptions) - - this.setupListeners({ - markersList: this.markers, - marker, - pickupPoint, - location, - index, - }) - - if (this.addressMarker && hasAddressCoords && shouldResetBounds) { - this.map.fitBounds(this.bounds) - isLargeScreen && this.map.panBy(PAN_LEFT_LAT, PAN_LEFT_LNG) - } - }) - - this.setSelectedPickupPoint() - } - - setSelectedPickupPoint = hoverPickupPoint => { - const { selectedPickupPoint } = this.props - - const pickupPointToMatch = hoverPickupPoint || selectedPickupPoint - - this.markers.forEach(markerObj => { - const isScaledMarker = - pickupPointToMatch && pickupPointToMatch.id === markerObj.pickupPoint - this.setIcon({ - marker: markerObj.marker, - width: isScaledMarker ? BIG_MARKER_WIDTH : MARKER_WIDTH, - height: isScaledMarker ? BIG_MARKER_HEIGHT : MARKER_HEIGHT, - }) - }) - this.searchMarkers.forEach(markerObj => { - const isScaledMarker = - pickupPointToMatch && pickupPointToMatch.id === markerObj.pickupPoint - this.setIcon({ - marker: markerObj.marker, - width: isScaledMarker ? BIG_MARKER_WIDTH : MARKER_WIDTH, - height: isScaledMarker ? BIG_MARKER_HEIGHT : MARKER_HEIGHT, - }) - }) - } - - resetMarkers = location => { - this.markers && - this.markers.forEach(markerObj => { - if (markerObj.pickupPoint) { - this.setIcon({ - marker: markerObj.marker, - width: MARKER_WIDTH, - height: MARKER_HEIGHT, - }) - } - }) - this.searchMarkers && - this.searchMarkers.forEach(searchMarkerObj => { - if (searchMarkerObj.pickupPoint) { - this.setIcon({ - marker: searchMarkerObj.marker, - width: MARKER_WIDTH, - height: MARKER_HEIGHT, - }) - } - }) - location && this.addressMarker && this.addressMarker.setPosition(location) - } - - setIcon({ marker, width, height, isSearching }) { - const { googleMaps } = this.props - - marker.setIcon({ - url: isSearching - ? searchingMarkerIcon - : marker && marker.icon && marker.icon.url, - size: new googleMaps.Size(width, height), - scaledSize: new googleMaps.Size(width, height), - }) - } - - setupListeners = ({ - markersList, - marker, - pickupPoint, - pickupPointsList, - index, - }) => { - const { - changeActivePickupDetails, - googleMaps, - setSelectedPickupPoint, - setShouldSearchArea, - updateLocationTab, - } = this.props - - const markerClickListener = googleMaps.event.addListener( - marker, - 'click', - () => { - pickupPointSelectionEvent({ selectionMethod: SELECTION_METHOD_MAP }) - this.resetMarkers() - this.setIcon({ - marker, - width: BIG_MARKER_WIDTH, - height: BIG_MARKER_HEIGHT, - isSearching: !!pickupPointsList, - }) - changeActivePickupDetails({ - pickupPoint: pickupPointsList ? pickupPointsList[index] : pickupPoint, - }) - setSelectedPickupPoint({ - pickupPoint: pickupPointsList ? pickupPointsList[index] : pickupPoint, - isBestPickupPoint: pickupPointsList - ? false - : index < BEST_PICKUPS_AMOUNT, - }) - updateLocationTab(HIDE_MAP) - setShouldSearchArea(false) - } - ) - - const markerHoverListener = googleMaps.event.addListener( - marker, - 'mouseover', - () => { - this.setIcon({ - marker, - width: BIG_MARKER_WIDTH, - height: BIG_MARKER_HEIGHT, - }) - } - ) - - const markerHoverOutListener = googleMaps.event.addListener( - marker, - 'mouseout', - () => { - if ( - this.props.selectedPickupPoint && - this.props.selectedPickupPoint.id === - (pickupPointsList ? pickupPointsList[index].id : pickupPoint.id) - ) { - return - } - this.setIcon({ marker, width: MARKER_WIDTH, height: MARKER_HEIGHT }) - } - ) - - markersList.push({ - marker, - markerClickListener, - markerHoverListener, - markerHoverOutListener, - pickupPoint: pickupPointsList - ? pickupPointsList[index].id - : pickupPoint.id, - }) - } - - recenterMap = location => { - if (!this.map) return - - this.map.panTo(location) - - this.props.setMapCenterLatLng(this.map.getCenter()) - this.toggleSearchArea() - - if (!this.props.isLargeScreen) return - - this.map.panBy(PAN_LEFT_LAT, PAN_LEFT_LNG) - } - - setZoom = zoom => { - if (!this.map) return - - this.map.setZoom(zoom) - } - getLocation = geoCoordinates => { - if (!this.props.googleMaps || !geoCoordinates) return - const [lng, lat] = geoCoordinates - const location = new this.props.googleMaps.LatLng(lat, lng) - return location - } - - isDifferentGeoCoords(a, b) { - return a[0] !== b[0] || a[1] !== b[1] - } - - render() { - return this.props.isLoadingGoogle ? ( - this.props.loadingElement - ) : ( -
+ const isScaledMarker = + selectedPickupPoint?.id === pickupId || hoveringIds.has(pickupId) + + return ( + { + pickupPointSelectionEvent({ + selectionMethod: SELECTION_METHOD_MAP, + }) + changeActivePickupDetails({ + pickupPoint, + }) + setSelectedPickupPoint({ + pickupPoint, + isBestPickupPoint: index < BEST_PICKUPS_AMOUNT, + }) + updateLocationTab(HIDE_MAP) + setShouldSearchArea(false) + }} + onMouseOver={() => { + this.setState((prevState) => { + return { + ...prevState, + hoveringIds: new Set(prevState.hoveringIds.values()).add( + pickupId + ), + } + }) + }} + onMouseOut={() => { + this.setState((prevState) => { + const updatedSet = new Set(prevState.hoveringIds.values()) + + updatedSet.delete(pickupId) + + return { + ...prevState, + hoveringIds: updatedSet, + } + }) + }} + icon={{ + url: markerIconImage, + size: isScaledMarker + ? new googleMaps.Size(BIG_MARKER_WIDTH, BIG_MARKER_HEIGHT) + : new googleMaps.Size(MARKER_WIDTH, MARKER_HEIGHT), + scaledSize: isScaledMarker + ? new googleMaps.Size(BIG_MARKER_WIDTH, BIG_MARKER_HEIGHT) + : new googleMaps.Size(MARKER_WIDTH, MARKER_HEIGHT), + }} + /> + ) + })} + ) } } @@ -736,33 +290,20 @@ Map.defaultProps = { } Map.propTypes = { - activePickupPoint: PropTypes.object, - activeState: PropTypes.string, - activatePickupDetails: PropTypes.func.isRequired, - address: AddressShapeWithValidation, + googleMaps: PropTypes.object, bestPickupOptions: PropTypes.array, - changeActivePickupDetails: PropTypes.func, + selectedPickupPoint: PropTypes.object, + address: AddressShapeWithValidation, externalPickupPoints: PropTypes.array, - geoCoordinates: PropTypes.array, - googleMaps: PropTypes.object, - hoverPickupPoint: PropTypes.object, isLargeScreen: PropTypes.bool, + pickupPoints: PropTypes.array, isLoadingGoogle: PropTypes.bool, - isPickupDetailsActive: PropTypes.bool, loadingElement: PropTypes.node, - onChangeAddress: PropTypes.func.isRequired, - pickupOptions: PropTypes.array, - pickupPoint: PropTypes.object, - pickupPoints: PropTypes.array, - rules: PropTypes.object.isRequired, - selectedPickupPoint: PropTypes.object, - selectedPickupPointGeolocation: PropTypes.array, - setActiveState: PropTypes.func.isRequired, - setActiveSidebarState: PropTypes.func.isRequired, - setMapCenterLatLng: PropTypes.func.isRequired, - setSelectedPickupPoint: PropTypes.func.isRequired, + changeActivePickupDetails: PropTypes.func, setShouldSearchArea: PropTypes.func.isRequired, updateLocationTab: PropTypes.func.isRequired, + setSelectedPickupPoint: PropTypes.func.isRequired, + children: PropTypes.node, } export default injectState(Map) From cb911f5c2212c39626d79673d1aac0757752e957 Mon Sep 17 00:00:00 2001 From: Lucas Cordeiro Date: Wed, 24 Nov 2021 16:49:48 -0300 Subject: [PATCH 07/15] Fix infinite loop when deselecting a pickup point --- react/components/Map.jsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/react/components/Map.jsx b/react/components/Map.jsx index 87e08a52..a982fc57 100644 --- a/react/components/Map.jsx +++ b/react/components/Map.jsx @@ -62,7 +62,9 @@ class Map extends Component { selectedPickupPoint?.address?.geoCoordinates ?? [] - const isInBounds = bounds?.contains(this.getLocation(geoCoordinates)) + const isInBounds = + geoCoordinates.length === 2 && + bounds?.contains(this.getLocation(geoCoordinates)) if (selectedPickupPoint !== prevProps.selectedPickupPoint && !isInBounds) { this.setState({ @@ -115,7 +117,9 @@ class Map extends Component { const bounds = hasAddressCoords ? new googleMaps.LatLngBounds() : null if (bounds != null) { - bounds.extend(this.getLocation(mapCenter)) + if (mapCenter.length === 2) { + bounds.extend(this.getLocation(mapCenter)) + } bestPickupOptions?.forEach((pickupPoint) => { const location = this.getLocation( From bd3420c89400fef46fbde232c3bbc6b606a4e8cd Mon Sep 17 00:00:00 2001 From: Lucas Cordeiro Date: Tue, 30 Nov 2021 10:55:23 -0300 Subject: [PATCH 08/15] Fix zoom out when changing distant locations --- react/components/GoogleMap.tsx | 8 ++++++++ react/components/Map.jsx | 16 ++++++++++------ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/react/components/GoogleMap.tsx b/react/components/GoogleMap.tsx index 4a134fbe..91407a76 100644 --- a/react/components/GoogleMap.tsx +++ b/react/components/GoogleMap.tsx @@ -263,6 +263,10 @@ export const GoogleMap = forwardRef( map.setCenter(geoCoordinatesToLatLng(center)) + if (map.getZoom() !== STANDARD_ZOOM) { + map.setZoom(STANDARD_ZOOM) + } + if (isLargeScreen) { map.panBy(PAN_LEFT_LAT, PAN_LEFT_LNG) } @@ -279,6 +283,10 @@ export const GoogleMap = forwardRef( map.fitBounds(bounds) + if ((map.getZoom() ?? STANDARD_ZOOM) > STANDARD_ZOOM) { + map.setZoom(STANDARD_ZOOM) + } + if (isLargeScreen) { map.panBy(PAN_LEFT_LAT, PAN_LEFT_LNG) } diff --git a/react/components/Map.jsx b/react/components/Map.jsx index a982fc57..89d6bf12 100644 --- a/react/components/Map.jsx +++ b/react/components/Map.jsx @@ -97,6 +97,7 @@ class Map extends Component { setSelectedPickupPoint, updateLocationTab, setShouldSearchArea, + isSearching, } = this.props const { hoveringIds, mapCenter } = this.state @@ -121,13 +122,15 @@ class Map extends Component { bounds.extend(this.getLocation(mapCenter)) } - bestPickupOptions?.forEach((pickupPoint) => { - const location = this.getLocation( - pickupPoint.pickupStoreInfo.address.geoCoordinates - ) + if (!isSearching) { + bestPickupOptions?.forEach((pickupPoint) => { + const location = this.getLocation( + pickupPoint.pickupStoreInfo.address.geoCoordinates + ) - bounds.extend(location) - }) + bounds.extend(location) + }) + } } return ( @@ -300,6 +303,7 @@ Map.propTypes = { address: AddressShapeWithValidation, externalPickupPoints: PropTypes.array, isLargeScreen: PropTypes.bool, + isSearching: PropTypes.bool, pickupPoints: PropTypes.array, isLoadingGoogle: PropTypes.bool, loadingElement: PropTypes.node, From 96e8b6a4de76f9a7ae509e10695b71e3af853dab Mon Sep 17 00:00:00 2001 From: Lucas Cordeiro Date: Tue, 30 Nov 2021 17:18:50 -0300 Subject: [PATCH 09/15] Fix tests --- package.json | 30 +- react/components/__tests__/Input.test.js | 26 +- .../__tests__/PickupPointDetails.test.js | 149 +- .../components/__tests__/ProductItems.test.js | 8 +- .../PickupPointDetails.test.js.snap | 2 +- react/package.json | 30 +- .../setupTestsAfterEnv.js | 4 - react/test-utils.js/index.js | 20 +- react/utils/__tests__/__mocks__/newAddress.js | 18 - react/yarn.lock | 7431 ++++++++++++++++- yarn.lock | 3577 +------- 11 files changed, 7244 insertions(+), 4051 deletions(-) rename setupTestsAfterEnv.js => react/setupTestsAfterEnv.js (69%) delete mode 100644 react/utils/__tests__/__mocks__/newAddress.js diff --git a/package.json b/package.json index 9628bf0d..25aff678 100644 --- a/package.json +++ b/package.json @@ -15,9 +15,9 @@ "lint-fix": "yarn lint --fix", "lint:locales": "intl-equalizer", "lint:ts": "tsc --noEmit -p react/tsconfig.json", - "test": "jest --env=jsdom", - "test:coverage": "jest --env=jsdom --coverage", - "test:watch": "jest --env=jsdom --watch", + "test": "yarn --cwd react test", + "test:coverage": "yarn --cwd react test --coverage", + "test:watch": "yarn --cwd react test --watch", "prepublishOnly": "npm run symlink:remove && npm run symlink && npm run build && npm run symlink:remove", "build:link": "npm link && watch 'npm run build' src", "postreleasy": "npm publish --access public" @@ -37,36 +37,14 @@ ] }, "devDependencies": { - "@babel/core": "^7.16.0", - "@babel/preset-env": "^7.16.0", - "@babel/preset-react": "^7.16.0", - "@babel/preset-typescript": "^7.16.0", "@vtex/prettier-config": "^0.3.6", - "babel-jest": "^27.3.1", - "enzyme": "^3.4.1", - "enzyme-adapter-react-16": "^1.2.0", - "enzyme-react-intl": "^2.0.0", "eslint": "^7", "eslint-config-vtex": "^14.1.1", "eslint-config-vtex-react": "^8.2.0", "husky": "^4.2.3", - "i18n-iso-countries": "^3.5.0", - "identity-obj-proxy": "^3.0.0", - "jest": "^27.3.1", - "jest-enzyme": "^6.0.3", "lint-staged": "^10.1.1", - "mockdate": "^2.0.2", "nwb": "^0.23.0", "prettier": "^2.4.1", - "react": "^16.3.2", - "react-dom": "^16.3.2", - "react-hot-loader": "^3.1.3", - "react-intl": "^2.8.0", - "react-redux": "^5.0.7", - "react-test-renderer": "^16.2.0", - "react-testing-library": "^6.0.0", - "redux": "^4.0.0", - "typescript": "^3.8.3", - "watch": "^1.0.2" + "typescript": "^3.8.3" } } diff --git a/react/components/__tests__/Input.test.js b/react/components/__tests__/Input.test.js index 8578a2a3..2e01c0d0 100644 --- a/react/components/__tests__/Input.test.js +++ b/react/components/__tests__/Input.test.js @@ -1,15 +1,18 @@ import React from 'react' -import Input from '../Input' -import { shallowWithIntl, loadTranslation, setLocale } from 'enzyme-react-intl' +import { shallow } from 'enzyme' import { Provider } from 'react-redux' -import IntlContainer from '../../containers/IntlContainer' import renderer from 'react-test-renderer' +import { IntlProvider } from 'react-intl' -loadTranslation('./messages/pt-BR.json') -setLocale('pt') +import messages from '../../../messages/pt-BR.json' +import IntlContainer from '../../containers/IntlContainer' +import Input from '../Input' describe('Input', () => { - let state, store, props, onChange + let state + let store + let props + let onChange beforeEach(() => { onChange = jest.fn() @@ -88,6 +91,7 @@ describe('Input', () => { ) .toJSON() + expect(wrapper).toMatchSnapshot() }) @@ -160,11 +164,19 @@ describe('Input', () => { ) .toJSON() + expect(wrapper).toMatchSnapshot() }) it('should simulate onChange', () => { - const wrapper = shallowWithIntl() + const wrapper = shallow( + + + + ) wrapper.simulate('change', { target: { diff --git a/react/components/__tests__/PickupPointDetails.test.js b/react/components/__tests__/PickupPointDetails.test.js index 25f3a4f8..d7819612 100644 --- a/react/components/__tests__/PickupPointDetails.test.js +++ b/react/components/__tests__/PickupPointDetails.test.js @@ -1,13 +1,13 @@ import React from 'react' -import PickupPointDetails from '../PickupPointDetails' - -import { mount } from 'enzyme' -import { render, fireEvent } from 'react-testing-library' +import { render, screen } from '@vtex/test-tools/react' import { Provider } from 'react-redux' import { IntlProvider } from 'react-intl' import renderer from 'react-test-renderer' import { addValidation } from '@vtex/address-form' import BRA from '@vtex/address-form/lib/country/BRA' +import userEvent from '@testing-library/user-event' + +import PickupPointDetails from '../PickupPointDetails' import { PICKUP, DELIVERY, PICKUP_IN_STORE, SIDEBAR } from '../../constants' import messages from '../../../messages/en.json' import { ModalStateContext } from '../../modalStateContext' @@ -16,16 +16,17 @@ import ModalState from '../../ModalState' jest.mock('../../utils/Images', () => ({ fixImageUrl: () => 'teste.png', })) + describe('PickupPointDetails', () => { - let state, - store, - props, - handleChangeActiveSLAOption, - handleClosePickupPointsModal, - togglePickupDetails, - selectNextPickupPoint, - setActiveSidebarState, - modalState + let state + let store + let props + let handleChangeActiveSLAOption + let handleClosePickupPointsModal + let togglePickupDetails + let selectNextPickupPoint + let setActiveSidebarState + let modalState const address = { addressType: 'residential', @@ -53,11 +54,11 @@ describe('PickupPointDetails', () => { modalState = { activeState: SIDEBAR, - shouldUseMaps: true, setActiveSidebarState, shouldUseMaps: false, setShouldSearchArea: jest.fn(), setSelectedPickupPoint: jest.fn(), + setHoverPickupPoint: jest.fn(), selectNextPickupPoint, pickupPoints: [ { @@ -65,7 +66,11 @@ describe('PickupPointDetails', () => { pickupPointId: '1', friendlyName: 'Loja VTEX', name: 'test', - address: { geoCoordinates: [123, 123] }, + address: { + addressId: '19fk', + addressType: 'residential', + geoCoordinates: [123, 123], + }, businessHours: [ { DayOfWeek: 0, OpeningTime: '02:00:00', ClosingTime: '14:00:00' }, { DayOfWeek: 6, OpeningTime: '03:00:00', ClosingTime: '11:30:00' }, @@ -76,7 +81,11 @@ describe('PickupPointDetails', () => { pickupPointId: '2', friendlyName: 'Outra Loja', name: 'test2', - address: { geoCoordinates: [125, 125] }, + address: { + addressId: '19fk', + addressType: 'residential', + geoCoordinates: [125, 125], + }, businessHours: [ { ClosingTime: '12:00:00', DayOfWeek: 0, OpeningTime: '02:00:00' }, { ClosingTime: '22:30:00', DayOfWeek: 6, OpeningTime: '16:00:00' }, @@ -88,7 +97,11 @@ describe('PickupPointDetails', () => { id: '1', pickupPointId: '1', friendlyName: 'Loja VTEX', - address: { geoCoordinates: [123, 123] }, + address: { + addressId: '19fk', + addressType: 'residential', + geoCoordinates: [123, 123], + }, pickupStoreInfo: { isPickupStore: true, friendlyName: 'Loja VTEX', @@ -116,7 +129,11 @@ describe('PickupPointDetails', () => { id: '2', pickupPointId: '2', friendlyName: 'Outra Loja', - address: { geoCoordinates: [125, 125] }, + address: { + addressId: '19fk', + addressType: 'residential', + geoCoordinates: [125, 125], + }, pickupStoreInfo: { isPickupStore: true, friendlyName: 'Outra Loja', @@ -147,7 +164,11 @@ describe('PickupPointDetails', () => { shippingEstimate: '1bd', pickupStoreInfo: { friendlyName: 'Loja VTEX', - address: { geoCoordinates: [123, 123] }, + address: { + addressId: '19fk', + addressType: 'residential', + geoCoordinates: [123, 123], + }, }, deliveryChannel: PICKUP_IN_STORE, id: '1', @@ -238,6 +259,8 @@ describe('PickupPointDetails', () => { pickupStoreInfo: { friendlyName: 'test', address: { + addressId: '19fk', + addressType: 'residential', geoCoordinates: [123, 123], }, }, @@ -251,6 +274,8 @@ describe('PickupPointDetails', () => { pickupStoreInfo: { friendlyName: 'test', address: { + addressId: '19fk', + addressType: 'residential', geoCoordinates: [123, 123], }, }, @@ -449,8 +474,12 @@ describe('PickupPointDetails', () => { startsWithCurrencySymbol: true, }, }, + bestPickupOptions: [], pickupPointId: '2', - pickupOptionGeolocations: [[123, 123], [123, 123]], + pickupOptionGeolocations: [ + [123, 123], + [123, 123], + ], googleMapsKey: '1234', address: addValidation({ addressType: 'residential', @@ -490,6 +519,7 @@ describe('PickupPointDetails', () => { togglePickupDetails, logisticsInfo: [], pickupPointInfo: {}, + shouldUseMaps: true, } }) @@ -500,52 +530,54 @@ describe('PickupPointDetails', () => { + messages={{ ...messages, 'country.BRA': 'BRA' }} + > ) .toJSON() + expect(wrapper).toMatchSnapshot() }) it('should simulate go back to list of pickups', () => { - const wrapper = mount( + render( - - - + ) - const backLink = wrapper.find('button.pkpmodal-details-back-lnk') + const backButton = screen.getByRole('button', { + name: 'See all Pick Up In Store sites', + }) - backLink.simulate('click') + userEvent.click(backButton) - expect(setActiveSidebarState.mock.calls).toHaveLength(1) + expect(setActiveSidebarState).toHaveBeenCalledTimes(1) }) it('should simulate confirm a pickupPoint', () => { - const wrapper = mount( + jest.useFakeTimers() + + render( - - - + ) - const confirmButton = wrapper.find('.pkpmodal-details-confirm-btn') + const confirmButton = screen.getByRole('button', { + name: 'Select this store', + }) + + userEvent.click(confirmButton) - confirmButton.simulate('click') + jest.runAllTimers() - expect(handleClosePickupPointsModal.mock.calls).toHaveLength(1) + expect(handleClosePickupPointsModal).toHaveBeenCalledTimes(1) }) it('should show the right info about the selected pickup point', () => { @@ -555,47 +587,46 @@ describe('PickupPointDetails', () => { address={address} residentialAddress={undefined} askForGeolocation={false} - googleMapsKey={'1234'} + googleMapsKey="1234" isSearching={false} items={state.orderForm.items} - mapStatus={'HIDE_MAP'} + mapStatus="HIDE_MAP" logisticsInfo={modalState.logisticsInfo} pickupPoints={modalState.pickupPoints} pickupOptions={modalState.pickupOptions} salesChannel={undefined} orderFormId={undefined} - selectedPickupPoint={modalState.selectedPickupPoint}> - - - + selectedPickupPoint={modalState.selectedPickupPoint} + > + ) expect(queryByText('Loja VTEX')).toBeTruthy() - expect(queryByText('03:00')).toBeTruthy() - expect(queryByText('11:30')).toBeTruthy() + expect(queryByText('3:00 AM')).toBeTruthy() + expect(queryByText('11:30 AM')).toBeTruthy() const nextPickupPointButton = getByTestId('goToNextPickupPoint') const previousPickupPointButton = getByTestId('goToPreviousPickupPoint') - fireEvent.click(nextPickupPointButton) + userEvent.click(nextPickupPointButton) + expect(queryByText('Loja VTEX')).toBeFalsy() - expect(queryByText('03:00')).toBeFalsy() - expect(queryByText('11:30')).toBeFalsy() + expect(queryByText('3:00 AM')).toBeFalsy() + expect(queryByText('11:30 AM')).toBeFalsy() expect(queryByText('Outra Loja')).toBeTruthy() - expect(queryByText('16:00')).toBeTruthy() - expect(queryByText('22:30')).toBeTruthy() + expect(queryByText('4:00 PM')).toBeTruthy() + expect(queryByText('10:30 PM')).toBeTruthy() + + userEvent.click(previousPickupPointButton) - fireEvent.click(previousPickupPointButton) expect(queryByText('Outra Loja')).toBeFalsy() - expect(queryByText('16:00')).toBeFalsy() - expect(queryByText('22:30')).toBeFalsy() + expect(queryByText('4:00 PM')).toBeFalsy() + expect(queryByText('10:30 PM')).toBeFalsy() expect(queryByText('Loja VTEX')).toBeTruthy() - expect(queryByText('03:00')).toBeTruthy() - expect(queryByText('11:30')).toBeTruthy() + expect(queryByText('3:00 AM')).toBeTruthy() + expect(queryByText('11:30 AM')).toBeTruthy() }) }) diff --git a/react/components/__tests__/ProductItems.test.js b/react/components/__tests__/ProductItems.test.js index 496e438d..1b751bd8 100644 --- a/react/components/__tests__/ProductItems.test.js +++ b/react/components/__tests__/ProductItems.test.js @@ -1,8 +1,7 @@ import React from 'react' -import ProductItems from '../ProductItems' -import { shallowWithIntl, loadTranslation } from 'enzyme-react-intl' +import { shallow } from 'enzyme' -loadTranslation('./messages/pt-BR.json') +import ProductItems from '../ProductItems' describe('ProductItems', () => { it('should render self and subcomponents', () => { @@ -28,7 +27,8 @@ describe('ProductItems', () => { }, ], } - const wrapper = shallowWithIntl() + + const wrapper = shallow() expect(wrapper.find('img').prop('src')).toBe( '//basedevmkp.vteximg.com.br/arquivos/ids/168552-50-50' diff --git a/react/components/__tests__/__snapshots__/PickupPointDetails.test.js.snap b/react/components/__tests__/__snapshots__/PickupPointDetails.test.js.snap index 9fb71564..6991fa35 100644 --- a/react/components/__tests__/__snapshots__/PickupPointDetails.test.js.snap +++ b/react/components/__tests__/__snapshots__/PickupPointDetails.test.js.snap @@ -58,7 +58,7 @@ exports[`PickupPointDetails should render self and components 1`] = `