Skip to content

Commit

Permalink
⚡ [#76] Dynamically load Map component, using a separate chunk
Browse files Browse the repository at this point in the history
Our React map component is now dynamically loaded, resulting in a separate chunk
of Javascript code that only gets loaded if/when map components are used in the
form, saving quite a bit of bytes for other forms.
  • Loading branch information
sergei-maertens committed Jan 24, 2025
1 parent 9a58271 commit 71a688b
Show file tree
Hide file tree
Showing 9 changed files with 353 additions and 340 deletions.
3 changes: 0 additions & 3 deletions .storybook/preview.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import 'design-token-editor/lib/css/root.css';
import 'flatpickr';
import 'flatpickr/dist/l10n/nl.js';
import lodash from 'lodash';
import {fixIconUrls as fixLeafletIconUrls} from 'map';
import {initialize, mswLoader} from 'msw-storybook-addon';
import {Formio, Templates} from 'react-formio';
import {setAppElement} from 'react-modal';
Expand Down Expand Up @@ -39,8 +38,6 @@ initialize({
},
});

fixLeafletIconUrls();

// Added because of the warning for the react-modal
// This is needed so screen readers don't see main content when modal is opened
setAppElement('#storybook-root');
Expand Down
9 changes: 7 additions & 2 deletions src/components/FormStepSummary/ComponentValueDisplay.jsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import PropTypes from 'prop-types';
import React from 'react';
import React, {Suspense} from 'react';
import {FormattedDate, FormattedMessage, FormattedNumber, FormattedTime, useIntl} from 'react-intl';

import Anchor from 'components/Anchor';
import Body from 'components/Body';
import CoSignOld from 'components/CoSign';
import Image from 'components/Image';
import List from 'components/List';
import Loader from 'components/Loader';
import Map from 'components/Map';
import {getFormattedDateString, getFormattedTimeString} from 'utils';

Expand Down Expand Up @@ -189,7 +190,11 @@ const MapDisplay = ({component, value}) => {
return <EmptyDisplay />;
}

return <Map geoJsonGeometry={value} disabled tileLayerUrl={component.tileLayerUrl} />;
return (
<Suspense fallback={<Loader modifiers={['centered']} />}>
<Map geoJsonGeometry={value} disabled tileLayerUrl={component.tileLayerUrl} />
</Suspense>
);
};

const CoSignDisplay = ({value}) => {
Expand Down
306 changes: 306 additions & 0 deletions src/components/Map/LeafletMap.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,306 @@
import * as Leaflet from 'leaflet';
import {GeoSearchControl} from 'leaflet-geosearch';
import PropTypes from 'prop-types';
import {useContext, useEffect, useRef} from 'react';
import {useIntl} from 'react-intl';
import {FeatureGroup, MapContainer, TileLayer, useMap} from 'react-leaflet';
import {EditControl} from 'react-leaflet-draw';
import {useGeolocation} from 'react-use';

import {ConfigContext} from 'Context';
import {getBEMClassName} from 'utils';

import NearestAddress from './NearestAddress';
import {DEFAULT_INTERACTIONS, DEFAULT_LAT_LNG, DEFAULT_ZOOM} from './constants';
import {CRS_RD, TILE_LAYER_RD, initialize} from './init';
import OpenFormsProvider from './provider';
import {
applyLeafletTranslations,
leafletGestureHandlingText,
searchControlMessages,
} from './translations';

// Run some Leaflet-specific patches...
initialize();

const useDefaultCoordinates = () => {
// FIXME: can't call hooks conditionally
const {loading, latitude, longitude, error} = useGeolocation();
// it's possible the user declined permissions (error.code === 1) to access the
// location, or the location could not be determined. In that case, fall back to the
// hardcoded default. See Github issue
// https://github.com/open-formulieren/open-forms/issues/864 and the docs on
// GeolocationPositionError:
// https://developer.mozilla.org/en-US/docs/Web/API/GeolocationPositionError
if (error) {
return null;
}
if (!navigator.geolocation) return null;
if (loading) return null;
return [latitude, longitude];

Check warning on line 40 in src/components/Map/LeafletMap.jsx

View check run for this annotation

Codecov / codecov/patch

src/components/Map/LeafletMap.jsx#L40

Added line #L40 was not covered by tests
};

const getCoordinates = geoJsonGeometry => {
if (!geoJsonGeometry) {
return null;

Check warning on line 45 in src/components/Map/LeafletMap.jsx

View check run for this annotation

Codecov / codecov/patch

src/components/Map/LeafletMap.jsx#L45

Added line #L45 was not covered by tests
}

const center = Leaflet.geoJSON(geoJsonGeometry).getBounds().getCenter();
return [center.lat, center.lng];
};

const LeaftletMap = ({
geoJsonGeometry,
onGeoJsonGeometrySet,
defaultCenter = DEFAULT_LAT_LNG,
defaultZoomLevel = DEFAULT_ZOOM,
disabled = false,
interactions = DEFAULT_INTERACTIONS,
tileLayerUrl = TILE_LAYER_RD.url,
}) => {
const featureGroupRef = useRef();
const intl = useIntl();
const defaultCoordinates = useDefaultCoordinates();
const geoJsonCoordinates = getCoordinates(geoJsonGeometry);
const coordinates = geoJsonCoordinates ?? defaultCoordinates;

const modifiers = disabled ? ['disabled'] : [];
const className = getBEMClassName('leaflet-map', modifiers);

useEffect(() => {
applyLeafletTranslations(intl);
}, [intl]);

const onFeatureCreate = event => {
updateGeoJsonGeometry(event.layer);
};

const onFeatureDelete = () => {
// The value `null` is needed to make sure that Formio actually updates the value.
// node_modules/formiojs/components/_classes/component/Component.js:2528
onGeoJsonGeometrySet?.(null);

Check warning on line 81 in src/components/Map/LeafletMap.jsx

View check run for this annotation

Codecov / codecov/patch

src/components/Map/LeafletMap.jsx#L81

Added line #L81 was not covered by tests
};

const onSearchMarkerSet = event => {
updateGeoJsonGeometry(event.marker);
};

const updateGeoJsonGeometry = newFeatureLayer => {
// Remove all existing shapes from the map, ensuring that shapes are only added through
// `geoJsonGeometry` changes.
featureGroupRef.current?.clearLayers();
onGeoJsonGeometrySet?.(newFeatureLayer.toGeoJSON().geometry);
};

useEffect(() => {
// Remove all shapes from the map.
// Only `geoJsonGeometry` should be shown on the map.
featureGroupRef.current?.clearLayers();
if (!geoJsonGeometry) {
return;

Check warning on line 100 in src/components/Map/LeafletMap.jsx

View check run for this annotation

Codecov / codecov/patch

src/components/Map/LeafletMap.jsx#L100

Added line #L100 was not covered by tests
}
// Add the current `geoJsonGeometry` as shape
const layer = Leaflet.GeoJSON.geometryToLayer(geoJsonGeometry);
featureGroupRef.current?.addLayer(layer);
}, [geoJsonGeometry]);

return (
<>
<MapContainer
center={defaultCenter}
zoom={defaultZoomLevel}
crs={CRS_RD}
attributionControl
className={className}
searchControl
gestureHandling
gestureHandlingOptions={{
text: {
touch: intl.formatMessage(leafletGestureHandlingText.touch),
scroll: intl.formatMessage(leafletGestureHandlingText.scroll),
scrollMac: intl.formatMessage(leafletGestureHandlingText.scrollMac),
},
duration: 3000,
}}
>
<TileLayer {...TILE_LAYER_RD} url={tileLayerUrl} />
<FeatureGroup ref={featureGroupRef}>
{!disabled && (
<EditControl
position="topright"
onCreated={onFeatureCreate}
onDeleted={onFeatureDelete}
edit={{
edit: false,
}}
draw={{
rectangle: false,
circle: false,
polyline: !!interactions?.polyline,
polygon: !!interactions?.polygon,
marker: !!interactions?.marker,
circlemarker: false,
}}
/>
)}
</FeatureGroup>
{coordinates && <MapView coordinates={coordinates} />}
{!disabled && (
<SearchControl
onMarkerSet={onSearchMarkerSet}
options={{
showMarker: false,
showPopup: false,
retainZoomLevel: false,
animateZoom: true,
autoClose: false,
searchLabel: intl.formatMessage(searchControlMessages.searchLabel),
keepResult: true,
updateMap: true,
notFoundMessage: intl.formatMessage(searchControlMessages.notFound),
}}
/>
)}
{disabled && <DisabledMapControls />}
</MapContainer>
{geoJsonCoordinates && geoJsonCoordinates.length && (
<NearestAddress coordinates={geoJsonCoordinates} />
)}
</>
);
};

LeaftletMap.propTypes = {
geoJsonGeometry: PropTypes.oneOfType([
PropTypes.shape({
type: PropTypes.oneOf(['Point']).isRequired,
coordinates: PropTypes.arrayOf(PropTypes.number).isRequired,
}),
PropTypes.shape({
type: PropTypes.oneOf(['LineString']).isRequired,
coordinates: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.number)).isRequired,
}),
PropTypes.shape({
type: PropTypes.oneOf(['Polygon']).isRequired,
coordinates: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.number)))
.isRequired,
}),
]),
onGeoJsonGeometrySet: PropTypes.func,
interactions: PropTypes.shape({
polyline: PropTypes.bool,
polygon: PropTypes.bool,
marker: PropTypes.bool,
}),
disabled: PropTypes.bool,
tileLayerUrl: PropTypes.string,
};

// Set the map view if coordinates are provided
const MapView = ({coordinates = null}) => {
const map = useMap();
useEffect(() => {
if (!coordinates || coordinates.length !== 2) return;
if (!coordinates.filter(value => isFinite(value)).length === 2) return;
map.setView(coordinates);
}, [map, coordinates]);
// rendering is done by leaflet, so just return null
return null;
};

MapView.propTypes = {
coordinates: PropTypes.arrayOf(PropTypes.number),
};

const SearchControl = ({onMarkerSet, options}) => {
const {baseUrl} = useContext(ConfigContext);
const map = useMap();
const intl = useIntl();

const {
showMarker,
showPopup,
retainZoomLevel,
animateZoom,
autoClose,
searchLabel,
keepResult,
updateMap,
notFoundMessage,
} = options;

const buttonLabel = intl.formatMessage(searchControlMessages.buttonLabel);

useEffect(() => {
const provider = new OpenFormsProvider(baseUrl);
const searchControl = new GeoSearchControl({
provider: provider,
style: 'button',
showMarker,
showPopup,
retainZoomLevel,
animateZoom,
autoClose,
searchLabel,
keepResult,
updateMap,
notFoundMessage,
});

searchControl.button.setAttribute('aria-label', buttonLabel);
map.addControl(searchControl);
map.on('geosearch/showlocation', onMarkerSet);

return () => {
map.off('geosearch/showlocation', onMarkerSet);
map.removeControl(searchControl);
};
}, [
map,
onMarkerSet,
baseUrl,
showMarker,
showPopup,
retainZoomLevel,
animateZoom,
autoClose,
searchLabel,
keepResult,
updateMap,
notFoundMessage,
buttonLabel,
]);

return null;
};

SearchControl.propTypes = {
onMarkerSet: PropTypes.func.isRequired,
options: PropTypes.shape({
showMarker: PropTypes.bool.isRequired,
showPopup: PropTypes.bool.isRequired,
retainZoomLevel: PropTypes.bool.isRequired,
animateZoom: PropTypes.bool.isRequired,
autoClose: PropTypes.bool.isRequired,
searchLabel: PropTypes.string.isRequired,
keepResult: PropTypes.bool.isRequired,
updateMap: PropTypes.bool.isRequired,
notFoundMessage: PropTypes.string.isRequired,
}),
};

const DisabledMapControls = () => {
const map = useMap();
useEffect(() => {
map.dragging.disable();
map.touchZoom.disable();
map.doubleClickZoom.disable();
map.scrollWheelZoom.disable();
map.boxZoom.disable();
map.keyboard.disable();

Check warning on line 300 in src/components/Map/LeafletMap.jsx

View check run for this annotation

Codecov / codecov/patch

src/components/Map/LeafletMap.jsx#L293-L300

Added lines #L293 - L300 were not covered by tests
if (map.tap) map.tap.disable();
}, [map]);
return null;

Check warning on line 303 in src/components/Map/LeafletMap.jsx

View check run for this annotation

Codecov / codecov/patch

src/components/Map/LeafletMap.jsx#L303

Added line #L303 was not covered by tests
};

export default LeaftletMap;
11 changes: 11 additions & 0 deletions src/components/Map/constants.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// AVOID importing leaflet (or related libraries here) to ensure we don't break the
// bundle chunks.

// Roughly the center of the Netherlands
export const DEFAULT_LAT_LNG = [52.1326332, 5.291266];
export const DEFAULT_ZOOM = 13;
export const DEFAULT_INTERACTIONS = {
marker: true,
polygon: false,
polyline: false,
};
Loading

0 comments on commit 71a688b

Please sign in to comment.