diff --git a/.nsprc b/.nsprc index cb9c5368aa..eb9adf46b3 100644 --- a/.nsprc +++ b/.nsprc @@ -4,6 +4,7 @@ "https://nodesecurity.io/advisories/157", "https://nodesecurity.io/advisories/577", "https://nodesecurity.io/advisories/654", - "https://nodesecurity.io/advisories/664" + "https://nodesecurity.io/advisories/664", + "https://nodesecurity.io/advisories/678" ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index 611001a6be..a0d8c4b028 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ way to update this template, but currently, we follow a pattern: * Patch (v0.0.**X**): Bug fixes and small changes to components. --- +## Upcoming version 2018-08-XX +* [change] Reusable SearchMap. Fixed the original reverted version. (Includes audit exception 678) + [#882](https://github.com/sharetribe/flex-template-web/pull/882) + ## v1.3.1 * [fix] Hotfix: reverting the usage of ReusableMapContainer due to production build error. diff --git a/src/components/SearchMap/ReusableMapContainer.js b/src/components/SearchMap/ReusableMapContainer.js index d9a4872c8c..b00376e727 100644 --- a/src/components/SearchMap/ReusableMapContainer.js +++ b/src/components/SearchMap/ReusableMapContainer.js @@ -1,8 +1,20 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { node, string } from 'prop-types'; +import mapValues from 'lodash/mapValues'; +import { IntlProvider } from 'react-intl'; +import messages from '../../translations/en.json'; +import config from '../../config'; + import css from './SearchMap.css'; +/** + * ReusableMapContainer makes Google Map usage more effective. This improves: + * - Performance: no need to load dynamic map every time user enters the search page view on SPA. + * - Efficient Google Maps usage: less unnecessary calls to instantiate a dynamic map. + * - Reaction to a bug when removing Google Map instance + * https://issuetracker.google.com/issues/35821412 + */ class ReusableMapContainer extends React.Component { constructor(props) { super(props); @@ -41,6 +53,29 @@ class ReusableMapContainer extends React.Component { } renderSearchMap() { + // Prepare rendering child (MapWithGoogleMap component) to new location + // We need to add translations (IntlProvider) for map overlay components + // + // NOTICE: Children rendered with ReactDOM.render doesn't have Router access + // You need to provide onClick functions and URLs as props. + const renderChildren = () => { + const isTestEnv = process.env.NODE_ENV === 'test'; + + // Locale should not affect the tests. We ensure this by providing + // messages with the key as the value of each message. + const testMessages = mapValues(messages, (val, key) => key); + const localeMessages = isTestEnv ? testMessages : messages; + + const children = ( + + {this.props.children} + + ); + + // Render children to created element + ReactDOM.render(children, this.el); + }; + const targetDomNode = document.getElementById(this.el.id); // Check if we have already added map somewhere on the DOM @@ -52,6 +87,7 @@ class ReusableMapContainer extends React.Component { // if no mountNode is found, append this outside SPA rendering tree (to document body) document.body.appendChild(this.el); } + renderChildren(); } else { this.el.classList.remove(css.reusableMapHidden); @@ -60,10 +96,14 @@ class ReusableMapContainer extends React.Component { // (but it's not yet moved to correct location of rendering tree). document.body.removeChild(this.el); this.mountNode.appendChild(this.el); + + // render children and call reattach + renderChildren(); + this.props.onReattach(); + } else { + renderChildren(); } } - - ReactDOM.render(this.props.children, this.el); } render() { diff --git a/src/components/SearchMap/SearchMap.js b/src/components/SearchMap/SearchMap.js index ec2be0e4b4..8f69a164a4 100644 --- a/src/components/SearchMap/SearchMap.js +++ b/src/components/SearchMap/SearchMap.js @@ -1,10 +1,14 @@ import React, { Component } from 'react'; import { arrayOf, bool, func, number, string, shape } from 'prop-types'; +import { withRouter } from 'react-router-dom'; import { withGoogleMap, GoogleMap } from 'react-google-maps'; import classNames from 'classnames'; import groupBy from 'lodash/groupBy'; import isEqual from 'lodash/isEqual'; import reduce from 'lodash/reduce'; +import routeConfiguration from '../../routeConfiguration'; +import { createResourceLocatorString } from '../../util/routes'; +import { createSlug } from '../../util/urlHelpers'; import { types as sdkTypes } from '../../util/sdkLoader'; import { propTypes } from '../../util/types'; import { obfuscatedCoordinates } from '../../util/maps'; @@ -12,6 +16,7 @@ import { googleBoundsToSDKBounds } from '../../util/googleMaps'; import { SearchMapInfoCard, SearchMapPriceLabel, SearchMapGroupLabel } from '../../components'; import config from '../../config'; +import ReusableMapContainer from './ReusableMapContainer'; import css from './SearchMap.css'; const LABEL_HANDLE = 'SearchMapLabel'; @@ -97,11 +102,13 @@ const MapWithGoogleMap = withGoogleMap(props => { infoCardOpen, isOpenOnModal, listings, - onCloseAsModal, onIdle, onListingClicked, + onListingInfoCardClicked, + createURLToListing, onMapLoad, zoom, + mapComponentRefreshToken, } = props; const listingArraysInLocations = reducedToArray(groupedByCoordinates(listings)); @@ -126,6 +133,7 @@ const MapWithGoogleMap = withGoogleMap(props => { className={LABEL_HANDLE} listing={listing} onListingClicked={onListingClicked} + mapComponentRefreshToken={mapComponentRefreshToken} /> ); } @@ -136,6 +144,7 @@ const MapWithGoogleMap = withGoogleMap(props => { className={LABEL_HANDLE} listings={listingArr} onListingClicked={onListingClicked} + mapComponentRefreshToken={mapComponentRefreshToken} /> ); }); @@ -144,9 +153,11 @@ const MapWithGoogleMap = withGoogleMap(props => { const openedCard = infoCardOpen ? ( ) : null; @@ -191,7 +202,11 @@ export class SearchMapComponent extends Component { this.listings = []; this.googleMap = null; + this.mapReattachmentCount = 0; this.state = { infoCardOpen: null }; + + this.createURLToListing = this.createURLToListing.bind(this); + this.onListingInfoCardClicked = this.onListingInfoCardClicked.bind(this); this.onListingClicked = this.onListingClicked.bind(this); this.onMapClicked = this.onMapClicked.bind(this); this.onMapLoadHandler = this.onMapLoadHandler.bind(this); @@ -214,10 +229,30 @@ export class SearchMapComponent extends Component { this.listings = []; } + createURLToListing(listing) { + const routes = routeConfiguration(); + + const id = listing.id.uuid; + const slug = createSlug(listing.attributes.title); + const pathParams = { id, slug }; + + return createResourceLocatorString('ListingPage', routes, pathParams, {}); + } + onListingClicked(listings) { this.setState({ infoCardOpen: listings }); } + onListingInfoCardClicked(listing) { + if (this.props.onCloseAsModal) { + this.props.onCloseAsModal(); + } + + // To avoid full page refresh we need to use internal router + const history = this.props.history; + history.push(this.createURLToListing(listing)); + } + onMapClicked(e) { // Close open listing popup / infobox, unless the click is attached to a price label const labelClicked = hasParentWithClassName(e.nativeEvent.target, LABEL_HANDLE); @@ -240,6 +275,7 @@ export class SearchMapComponent extends Component { const { className, rootClassName, + reusableContainerClassName, center, isOpenOnModal, listings: originalListings, @@ -257,34 +293,44 @@ export class SearchMapComponent extends Component { const listings = coordinatesConfig.fuzzy ? withCoordinatesObfuscated(listingsWithLocation) : listingsWithLocation; + const infoCardOpen = this.state.infoCardOpen; const isMapsLibLoaded = typeof window !== 'undefined' && window.google && window.google.maps; + const forceUpdateHandler = () => { + this.mapReattachmentCount += 1; + }; + // container element listens clicks so that opened SearchMapInfoCard can be closed /* eslint-disable jsx-a11y/no-static-element-interactions */ return isMapsLibLoaded ? ( - } - mapElement={
} - center={center} - isOpenOnModal={isOpenOnModal} - listings={listings} - activeListingId={activeListingId} - infoCardOpen={this.state.infoCardOpen} - onListingClicked={this.onListingClicked} - onMapLoad={this.onMapLoadHandler} - onIdle={() => { - if (this.googleMap) { - onIdle(this.googleMap); - } - }} - onCloseAsModal={() => { - if (onCloseAsModal) { - onCloseAsModal(); - } - }} - zoom={zoom} - /> + + } + mapElement={
} + center={center} + isOpenOnModal={isOpenOnModal} + listings={listings} + activeListingId={activeListingId} + infoCardOpen={infoCardOpen} + onListingClicked={this.onListingClicked} + onListingInfoCardClicked={this.onListingInfoCardClicked} + createURLToListing={this.createURLToListing} + onMapLoad={this.onMapLoadHandler} + onIdle={() => { + if (this.googleMap) { + onIdle(this.googleMap); + } + }} + onCloseAsModal={() => { + if (onCloseAsModal) { + onCloseAsModal(); + } + }} + zoom={zoom} + mapComponentRefreshToken={this.mapReattachmentCount} + /> + ) : (
); @@ -296,6 +342,7 @@ SearchMapComponent.defaultProps = { className: null, rootClassName: null, mapRootClassName: null, + reusableContainerClassName: null, bounds: null, center: new sdkTypes.LatLng(0, 0), activeListingId: null, @@ -311,6 +358,7 @@ SearchMapComponent.propTypes = { className: string, rootClassName: string, mapRootClassName: string, + reusableContainerClassName: string, bounds: propTypes.latlngBounds, center: propTypes.latlng, activeListingId: propTypes.uuid, @@ -323,8 +371,13 @@ SearchMapComponent.propTypes = { coordinatesConfig: shape({ fuzzy: bool.isRequired, }), + + // from withRouter + history: shape({ + push: func.isRequired, + }).isRequired, }; -const SearchMap = SearchMapComponent; +const SearchMap = withRouter(SearchMapComponent); export default SearchMap; diff --git a/src/components/SearchMapInfoCard/SearchMapInfoCard.js b/src/components/SearchMapInfoCard/SearchMapInfoCard.js index ede8af103e..1ae2a94548 100644 --- a/src/components/SearchMapInfoCard/SearchMapInfoCard.js +++ b/src/components/SearchMapInfoCard/SearchMapInfoCard.js @@ -1,18 +1,14 @@ import React, { Component } from 'react'; -import PropTypes from 'prop-types'; +import { arrayOf, bool, func, string } from 'prop-types'; import { OverlayView } from 'react-google-maps'; import { OVERLAY_VIEW } from 'react-google-maps/lib/constants'; import { compose } from 'redux'; -import { withRouter } from 'react-router-dom'; import { injectIntl, intlShape } from 'react-intl'; import classNames from 'classnames'; import config from '../../config'; -import routeConfiguration from '../../routeConfiguration'; import { propTypes } from '../../util/types'; import { formatMoney } from '../../util/currency'; import { ensureListing } from '../../util/data'; -import { createResourceLocatorString } from '../../util/routes'; -import { createSlug } from '../../util/urlHelpers'; import { ResponsiveImage } from '../../components'; import css from './SearchMapInfoCard.css'; @@ -37,22 +33,14 @@ const getPixelPositionOffset = (width, height) => { return { x: -1 * (width / 2), y: -1 * (height + 3) }; }; -const createURL = (routes, history, listing) => { - const id = listing.id.uuid; - const slug = createSlug(listing.attributes.title); - const pathParams = { id, slug }; - return createResourceLocatorString('ListingPage', routes, pathParams, {}); -}; - // ListingCard is the listing info without overlayview or carousel controls const ListingCard = props => { - const { className, clickHandler, history, intl, isInCarousel, listing } = props; + const { className, clickHandler, intl, isInCarousel, listing, urlToListing } = props; const { title, price } = listing.attributes; const formattedPrice = price && price.currency === config.currency ? formatMoney(intl, price) : price.currency; const firstImage = listing.images && listing.images.length > 0 ? listing.images[0] : null; - const urlToListing = createURL(routeConfiguration(), history, listing); // listing card anchor needs sometimes inherited border radius. const classes = classNames( @@ -69,8 +57,8 @@ const ListingCard = props => { href={urlToListing} onClick={e => { e.preventDefault(); - // Handle click callbacks and use internal router - clickHandler(urlToListing); + // Use clickHandler from props to call internal router + clickHandler(listing); }} >
@@ -202,22 +181,17 @@ class SearchMapInfoCard extends Component { SearchMapInfoCard.defaultProps = { className: null, rootClassName: null, - onClickCallback: null, }; SearchMapInfoCard.propTypes = { className: string, rootClassName: string, listings: arrayOf(propTypes.listing).isRequired, - onClickCallback: func, - - // from withRouter - history: shape({ - push: func.isRequired, - }).isRequired, + onListingInfoCardClicked: func.isRequired, + createURLToListing: func.isRequired, // from injectIntl intl: intlShape.isRequired, }; -export default compose(withRouter, injectIntl)(SearchMapInfoCard); +export default compose(injectIntl)(SearchMapInfoCard); diff --git a/src/containers/SearchPage/SearchPage.js b/src/containers/SearchPage/SearchPage.js index eaebf54e77..ed19ceb31f 100644 --- a/src/containers/SearchPage/SearchPage.js +++ b/src/containers/SearchPage/SearchPage.js @@ -251,9 +251,10 @@ export class SearchPageComponent extends Component { showAsModalMaxWidth={MODAL_BREAKPOINT} onManageDisableScrolling={onManageDisableScrolling} > -
+
{shouldShowSearchMap ? (
-