From 2f2e52c3103e9cb48e77766701a9a5fc9af6ad48 Mon Sep 17 00:00:00 2001 From: Jasmine Nguyen <89806888+jasminenguyennn@users.noreply.github.com> Date: Mon, 20 Nov 2023 10:43:59 +0000 Subject: [PATCH] feat: [DHIS2-13237] Enrollment coordinates in enrollment widget (#3141) Co-authored-by: Simona Domnisoru --- i18n/en.pot | 42 ++- .../useCommonEnrollmentDomainData.types.js | 1 + .../Actions/Actions.component.js | 26 +- .../Actions/Actions.container.js | 45 +-- .../AddLocation/AddLocation.component.js | 23 ++ .../Actions/AddLocation/addLocation.types.js | 6 + .../Actions/AddLocation/index.js | 2 + .../Coordinates/Coordinates.component.js | 256 ++++++++++++++++++ .../MapModal/Coordinates/Coordinates.types.js | 10 + .../MapModal/Coordinates/converters.js | 11 + .../Coordinates/coordinate.validator.js | 32 +++ .../MapModal/Coordinates/index.js | 3 + .../MapModal/MapModal.component.js | 27 ++ .../MapModal/MapModal.container.js | 32 +++ .../MapModal/MapModal.types.js | 18 ++ .../Polygon/ConditionalTooltip.component.js | 29 ++ .../Polygon/DeleteControl.component.js | 52 ++++ .../MapModal/Polygon/Polygon.component.js | 225 +++++++++++++++ .../MapModal/Polygon/Polygon.types.js | 24 ++ .../MapModal/Polygon/converters.js | 8 + .../MapModal/Polygon/index.js | 3 + .../WidgetEnrollment/MapModal/index.js | 3 + .../MiniMap/MiniMap.component.js | 80 ++++++ .../WidgetEnrollment/MiniMap/MiniMap.types.js | 13 + .../WidgetEnrollment/MiniMap/converters.js | 10 + .../WidgetEnrollment/MiniMap/index.js | 2 + .../WidgetEnrollment.component.js | 19 +- .../dataMutation/dataMutation.js | 67 +++++ .../WidgetEnrollment/hooks/useGeometry.js | 39 +++ .../WidgetEnrollment/hooks/useProgram.js | 4 +- 30 files changed, 1044 insertions(+), 68 deletions(-) create mode 100644 src/core_modules/capture-core/components/WidgetEnrollment/Actions/AddLocation/AddLocation.component.js create mode 100644 src/core_modules/capture-core/components/WidgetEnrollment/Actions/AddLocation/addLocation.types.js create mode 100644 src/core_modules/capture-core/components/WidgetEnrollment/Actions/AddLocation/index.js create mode 100644 src/core_modules/capture-core/components/WidgetEnrollment/MapModal/Coordinates/Coordinates.component.js create mode 100644 src/core_modules/capture-core/components/WidgetEnrollment/MapModal/Coordinates/Coordinates.types.js create mode 100644 src/core_modules/capture-core/components/WidgetEnrollment/MapModal/Coordinates/converters.js create mode 100644 src/core_modules/capture-core/components/WidgetEnrollment/MapModal/Coordinates/coordinate.validator.js create mode 100644 src/core_modules/capture-core/components/WidgetEnrollment/MapModal/Coordinates/index.js create mode 100644 src/core_modules/capture-core/components/WidgetEnrollment/MapModal/MapModal.component.js create mode 100644 src/core_modules/capture-core/components/WidgetEnrollment/MapModal/MapModal.container.js create mode 100644 src/core_modules/capture-core/components/WidgetEnrollment/MapModal/MapModal.types.js create mode 100644 src/core_modules/capture-core/components/WidgetEnrollment/MapModal/Polygon/ConditionalTooltip.component.js create mode 100644 src/core_modules/capture-core/components/WidgetEnrollment/MapModal/Polygon/DeleteControl.component.js create mode 100644 src/core_modules/capture-core/components/WidgetEnrollment/MapModal/Polygon/Polygon.component.js create mode 100644 src/core_modules/capture-core/components/WidgetEnrollment/MapModal/Polygon/Polygon.types.js create mode 100644 src/core_modules/capture-core/components/WidgetEnrollment/MapModal/Polygon/converters.js create mode 100644 src/core_modules/capture-core/components/WidgetEnrollment/MapModal/Polygon/index.js create mode 100644 src/core_modules/capture-core/components/WidgetEnrollment/MapModal/index.js create mode 100644 src/core_modules/capture-core/components/WidgetEnrollment/MiniMap/MiniMap.component.js create mode 100644 src/core_modules/capture-core/components/WidgetEnrollment/MiniMap/MiniMap.types.js create mode 100644 src/core_modules/capture-core/components/WidgetEnrollment/MiniMap/converters.js create mode 100644 src/core_modules/capture-core/components/WidgetEnrollment/MiniMap/index.js create mode 100644 src/core_modules/capture-core/components/WidgetEnrollment/dataMutation/dataMutation.js create mode 100644 src/core_modules/capture-core/components/WidgetEnrollment/hooks/useGeometry.js diff --git a/i18n/en.pot b/i18n/en.pot index 5078cc26d8..0dd6fab89a 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -1176,6 +1176,33 @@ msgstr "Mark for follow-up" msgid "Existing dates for auto-generated events will not be updated." msgstr "Existing dates for auto-generated events will not be updated." +msgid "Latitude" +msgstr "Latitude" + +msgid "Longitude" +msgstr "Longitude" + +msgid "Edit" +msgstr "Edit" + +msgid "Set coordinates" +msgstr "Set coordinates" + +msgid "Coordinates" +msgstr "Coordinates" + +msgid "Delete polygon" +msgstr "Delete polygon" + +msgid "Close without saving" +msgstr "Close without saving" + +msgid "Finish drawing before saving" +msgstr "Finish drawing before saving" + +msgid "Set area" +msgstr "Set area" + msgid "Enrollment date" msgstr "Enrollment date" @@ -1200,6 +1227,12 @@ msgstr "Last updated {{date}}" msgid "Cancelled" msgstr "Cancelled" +msgid "Add coordinates" +msgstr "Add coordinates" + +msgid "Add area" +msgstr "Add area" + msgid "Comments about this enrollment" msgstr "Comments about this enrollment" @@ -1323,9 +1356,6 @@ msgstr "Profile widget could not be loaded. Please try again later" msgid "{{TETName}} profile" msgstr "{{TETName}} profile" -msgid "Edit" -msgstr "Edit" - msgid "tracked entity instance" msgstr "tracked entity instance" @@ -1572,12 +1602,6 @@ msgstr "To time" msgid "Page {{currentPage}}" msgstr "Page {{currentPage}}" -msgid "Delete polygon" -msgstr "Delete polygon" - -msgid "Set area" -msgstr "Set area" - msgid "Area on map saved" msgstr "Area on map saved" diff --git a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/useCommonEnrollmentDomainData/useCommonEnrollmentDomainData.types.js b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/useCommonEnrollmentDomainData/useCommonEnrollmentDomainData.types.js index 7ca431aa31..688e293955 100644 --- a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/useCommonEnrollmentDomainData/useCommonEnrollmentDomainData.types.js +++ b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/useCommonEnrollmentDomainData/useCommonEnrollmentDomainData.types.js @@ -39,6 +39,7 @@ export type EnrollmentData = {| scheduledAt: string, trackedEntity: string, trackedEntityType: string, + geometry?: ?{ type: string, coordinates: [number, number] | Array<[number, number]>} |}; export type AttributeValue = {| diff --git a/src/core_modules/capture-core/components/WidgetEnrollment/Actions/Actions.component.js b/src/core_modules/capture-core/components/WidgetEnrollment/Actions/Actions.component.js index 4d07c56a61..0c56de8cb1 100644 --- a/src/core_modules/capture-core/components/WidgetEnrollment/Actions/Actions.component.js +++ b/src/core_modules/capture-core/components/WidgetEnrollment/Actions/Actions.component.js @@ -8,8 +8,10 @@ import { Complete } from './Complete'; import { Delete } from './Delete'; import { Followup } from './Followup'; import { AddNew } from './AddNew'; +import { AddLocation } from './AddLocation'; import type { PlainProps } from './actions.types'; import { LoadingMaskForButton } from '../../LoadingMasks'; +import { MapModal } from '../MapModal'; const styles = { actions: { @@ -35,13 +37,14 @@ export const ActionsPlain = ({ onlyEnrollOnce, classes, }: PlainProps) => { - const [open, setOpen] = useState(false); + const [isOpenActions, setOpenActions] = useState(false); + const [isOpenMap, setOpenMap] = useState(false); const handleOnUpdate = (arg) => { - setOpen(prev => !prev); + setOpenActions(false); onUpdate(arg); }; const handleOnDelete = (arg) => { - setOpen(prev => !prev); + setOpenActions(false); onDelete(arg); }; @@ -53,8 +56,8 @@ export const ActionsPlain = ({ small disabled={loading} className={classes.actions} - open={open} - onClick={() => setOpen(prev => !prev)} + open={isOpenActions} + onClick={() => setOpenActions(prev => !prev)} component={ loading ? null : ( @@ -72,6 +75,13 @@ export const ActionsPlain = ({ enrollment={enrollment} onUpdate={handleOnUpdate} /> + { + setOpenMap(true); + setOpenActions(false); + }} + /> + ) } @@ -94,6 +105,11 @@ export const ActionsPlain = ({ {i18n.t('We are processing your request.')} )} + {isOpenMap && } ); }; diff --git a/src/core_modules/capture-core/components/WidgetEnrollment/Actions/Actions.container.js b/src/core_modules/capture-core/components/WidgetEnrollment/Actions/Actions.container.js index 2ce98fcb38..1e3bab189e 100644 --- a/src/core_modules/capture-core/components/WidgetEnrollment/Actions/Actions.container.js +++ b/src/core_modules/capture-core/components/WidgetEnrollment/Actions/Actions.container.js @@ -1,24 +1,8 @@ // @flow -import { useDataMutation } from '@dhis2/app-runtime'; import React from 'react'; import { ActionsComponent } from './Actions.component'; import type { Props } from './actions.types'; -import { processErrorReports } from '../processErrorReports'; - -const enrollmentUpdate = { - resource: 'tracker?async=false&importStrategy=UPDATE', - type: 'create', - data: enrollment => ({ - enrollments: [enrollment], - }), -}; -const enrollmentDelete = { - resource: 'tracker?async=false&importStrategy=DELETE', - type: 'create', - data: enrollment => ({ - enrollments: [enrollment], - }), -}; +import { useUpdateEnrollment, useDeleteEnrollment } from '../dataMutation/dataMutation'; export const Actions = ({ enrollment = {}, @@ -29,31 +13,8 @@ export const Actions = ({ onSuccess, ...passOnProps }: Props) => { - const [updateMutation, { loading: updateLoading }] = useDataMutation( - enrollmentUpdate, - { - onComplete: () => { - refetchEnrollment(); - refetchTEI(); - onSuccess && onSuccess(); - }, - onError: (e) => { - onError && onError(processErrorReports(e)); - }, - }, - ); - const [deleteMutation, { loading: deleteLoading }] = useDataMutation( - enrollmentDelete, - { - onComplete: () => { - onDelete(); - onSuccess && onSuccess(); - }, - onError: (e) => { - onError && onError(processErrorReports(e)); - }, - }, - ); + const { updateMutation, updateLoading } = useUpdateEnrollment(refetchEnrollment, refetchTEI, onError); + const { deleteMutation, deleteLoading } = useDeleteEnrollment(onDelete, onError); return ( { + const label = useGeometryLabel(enrollment); + + if (!label) { + return null; + } + + return ( + } + label={label} + onClick={() => setOpenMap(true)} + /> + ); +}; diff --git a/src/core_modules/capture-core/components/WidgetEnrollment/Actions/AddLocation/addLocation.types.js b/src/core_modules/capture-core/components/WidgetEnrollment/Actions/AddLocation/addLocation.types.js new file mode 100644 index 0000000000..d004713319 --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetEnrollment/Actions/AddLocation/addLocation.types.js @@ -0,0 +1,6 @@ +// @flow + +export type Props = {| + enrollment: Object, + setOpenMap: (toogle: boolean) => void, +|}; diff --git a/src/core_modules/capture-core/components/WidgetEnrollment/Actions/AddLocation/index.js b/src/core_modules/capture-core/components/WidgetEnrollment/Actions/AddLocation/index.js new file mode 100644 index 0000000000..bdf4bdca7c --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetEnrollment/Actions/AddLocation/index.js @@ -0,0 +1,2 @@ +// @flow +export { AddLocation } from './AddLocation.component'; diff --git a/src/core_modules/capture-core/components/WidgetEnrollment/MapModal/Coordinates/Coordinates.component.js b/src/core_modules/capture-core/components/WidgetEnrollment/MapModal/Coordinates/Coordinates.component.js new file mode 100644 index 0000000000..cd9940c11a --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetEnrollment/MapModal/Coordinates/Coordinates.component.js @@ -0,0 +1,256 @@ +// @flow +import React, { useState, useMemo } from 'react'; +import classNames from 'classnames'; +import i18n from '@dhis2/d2-i18n'; +import { IconCross24, spacers, Modal, ModalTitle, ModalContent, ModalActions, Button, ButtonStrip } from '@dhis2/ui'; +import { ReactLeafletSearch } from 'react-leaflet-search-unpolyfilled'; +import { Map, TileLayer, Marker, withLeaflet } from 'react-leaflet'; +import { withStyles } from '@material-ui/core'; +import type { CoordinatesProps } from './Coordinates.types'; +import { CoordinateInput } from '../../../../../capture-ui/internal/CoordinateInput/CoordinateInput.component'; +import { isEqual } from '../../../../utils/valueEqualityChecker'; +import { isValidCoordinate } from './coordinate.validator'; +import { convertCoordinatesToServer } from './converters'; + +const styles = (theme: Theme) => ({ + modalContent: { + width: '100%', + }, + map: { + width: '100%', + height: 'calc(100vh - 380px)', + }, + inputWrapper: { + paddingTop: spacers.dp8, + display: 'flex', + }, + inputContent: { + flexGrow: 1, + }, + fieldButton: { + height: '42px !important', + width: 42, + borderRadius: '0 !important', + }, + errorContainer: { + backgroundColor: theme.palette.error.lighter, + color: theme.palette.error.main, + }, +}); + +const WrappedLeafletSearch = withLeaflet(ReactLeafletSearch); + +const CoordinatesPlain = ({ + classes, + center: initialCenter, + setOpen, + defaultValues, + onSetCoordinates, +}: CoordinatesProps) => { + const [position, setPosition] = useState(defaultValues); + const [center, setCenter] = useState(initialCenter); + const [tempLatitude, setTempLatitude] = useState(position?.[0]); + const [tempLongitude, setTempLongitude] = useState(position?.[1]); + const [isEditing, setEditing] = useState(!defaultValues); + const [isValid, setValid] = useState(true); + const hasErrors = useMemo(() => { + const changed = !isEqual(position, defaultValues); + return changed && !isValid; + }, [position, defaultValues, isValid]); + + const resetToDefaultValues = () => { + setCenter(initialCenter); + setPosition(defaultValues); + if (defaultValues) { + setTempLatitude(defaultValues[0]); + setTempLongitude(defaultValues[1]); + setEditing(false); + } else { + setTempLatitude(null); + setTempLongitude(null); + } + }; + + const onHandleMapClicked = (mapCoordinates) => { + if (isEditing) { + const { lat, lng } = mapCoordinates.latlng; + const newPosition: [number, number] = [lat, lng]; + setValid(true); + setPosition(newPosition); + setTempLatitude(lat); + setTempLongitude(lng); + } + }; + + const onSearch = (searchPosition: any) => { + setCenter(searchPosition); + setValid(true); + setTempLatitude(searchPosition[0]); + setTempLongitude(searchPosition[1]); + setPosition(searchPosition); + }; + + const renderMap = () => ( + { + if (ref?.leafletElement) { + ref.leafletElement.invalidateSize(); + } + }} + className={classes.map} + onClick={onHandleMapClicked} + > + + + {position && } + + ); + + const renderLatitude = () => ( + { + if (!latitude) { + return; + } + const longitude = tempLongitude || (position?.[1] ? position[1] : undefined); + if (!longitude) { + return; + } + if (!isValidCoordinate({ longitude: Number(longitude), latitude: Number(latitude) })) { + setPosition(null); + setValid(false); + return; + } + setValid(true); + const newPosition = [Number(latitude), longitude]; + setPosition(newPosition); + setCenter(newPosition); + }} + onChange={(latitude) => { + setTempLatitude(latitude); + }} + /> + ); + + const renderLongitude = () => ( + { + if (!longitude) { + return; + } + const latitude = tempLatitude || (position?.[1] ? position[0] : undefined); + if (!latitude) { + return; + } + if (!isValidCoordinate({ longitude: Number(longitude), latitude: Number(latitude) })) { + setPosition(null); + setValid(false); + return; + } + setValid(true); + const newPosition = [latitude, Number(longitude)]; + setPosition(newPosition); + setCenter(newPosition); + }} + onChange={(longitude) => { + setTempLongitude(longitude); + }} + /> + ); + + const renderFieldButton = () => ( +
+ {!isEditing ? ( + + ) : ( +
+ ); + + const renderActions = () => ( + + + + + ); + + return ( + + {i18n.t('Coordinates')} + + {renderMap()} +
+
+
{renderLatitude()}
+
{renderLongitude()}
+ {renderFieldButton()} +
+ {hasErrors && ( +
{i18n.t('Please provide valid coordinates')}
+ )} +
+
+ {renderActions()} +
+ ); +}; +export const Coordinates = withStyles(styles)(CoordinatesPlain); diff --git a/src/core_modules/capture-core/components/WidgetEnrollment/MapModal/Coordinates/Coordinates.types.js b/src/core_modules/capture-core/components/WidgetEnrollment/MapModal/Coordinates/Coordinates.types.js new file mode 100644 index 0000000000..56e3c5f689 --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetEnrollment/MapModal/Coordinates/Coordinates.types.js @@ -0,0 +1,10 @@ +// @flow + +export type CoordinatesProps = { + center: ?[number, number], + setOpen: (open: boolean) => void, + onSetCoordinates: (coordinates: ?[number, number] | ?Array<[number, number]>) => void, + defaultValues?: ?[number, number], + ...CssClasses, +} + diff --git a/src/core_modules/capture-core/components/WidgetEnrollment/MapModal/Coordinates/converters.js b/src/core_modules/capture-core/components/WidgetEnrollment/MapModal/Coordinates/converters.js new file mode 100644 index 0000000000..357a034be3 --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetEnrollment/MapModal/Coordinates/converters.js @@ -0,0 +1,11 @@ +// @flow + +export const convertCoordinatesToServer = (coordinates?: Array | null): ?[number, number] => { + if (!coordinates || !coordinates[0]) { + return null; + } + + const lng: number = coordinates[0][1]; + const lat: number = coordinates[0][0]; + return [lng, lat]; +}; diff --git a/src/core_modules/capture-core/components/WidgetEnrollment/MapModal/Coordinates/coordinate.validator.js b/src/core_modules/capture-core/components/WidgetEnrollment/MapModal/Coordinates/coordinate.validator.js new file mode 100644 index 0000000000..1d37df3233 --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetEnrollment/MapModal/Coordinates/coordinate.validator.js @@ -0,0 +1,32 @@ +// @flow + +type Location = { + longitude: number, + latitude: number, +}; + +function isNumValid(num) { + if (typeof num === 'number') { + return true; + } else if (typeof num === 'string') { + return num.match(/[^0-9.,-]+/) === null; + } + + return false; +} + +export const isValidCoordinate = (value: Location) => { + if (!value) { + return false; + } + + const { longitude, latitude } = value; + if (!isNumValid(latitude) || !isNumValid(longitude)) { + return false; + } + + const ld = parseInt(longitude, 10); + const lt = parseInt(latitude, 10); + + return ld >= -180 && ld <= 180 && lt >= -90 && lt <= 90; +}; diff --git a/src/core_modules/capture-core/components/WidgetEnrollment/MapModal/Coordinates/index.js b/src/core_modules/capture-core/components/WidgetEnrollment/MapModal/Coordinates/index.js new file mode 100644 index 0000000000..8d4c2efc14 --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetEnrollment/MapModal/Coordinates/index.js @@ -0,0 +1,3 @@ +// @flow +export { Coordinates } from './Coordinates.component'; + diff --git a/src/core_modules/capture-core/components/WidgetEnrollment/MapModal/MapModal.component.js b/src/core_modules/capture-core/components/WidgetEnrollment/MapModal/MapModal.component.js new file mode 100644 index 0000000000..355490f756 --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetEnrollment/MapModal/MapModal.component.js @@ -0,0 +1,27 @@ +// @flow +import React from 'react'; +import { dataElementTypes } from '../../../metaData'; +import type { MapModalComponentProps } from './MapModal.types'; +import { Coordinates } from './Coordinates'; +import { Polygon } from './Polygon'; + +export const MapModal = ({ type, center, setOpen, onSetCoordinates, defaultValues }: MapModalComponentProps) => ( + <> + {type === dataElementTypes.COORDINATE && ( + + )} + {type === dataElementTypes.POLYGON && ( + + )} + +); diff --git a/src/core_modules/capture-core/components/WidgetEnrollment/MapModal/MapModal.container.js b/src/core_modules/capture-core/components/WidgetEnrollment/MapModal/MapModal.container.js new file mode 100644 index 0000000000..b39d141b1d --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetEnrollment/MapModal/MapModal.container.js @@ -0,0 +1,32 @@ +// @flow +import React, { useCallback } from 'react'; +import { useGeometry } from '../hooks/useGeometry'; +import type { MapModalProps } from './MapModal.types'; +import { MapModal as MapModalComponent } from './MapModal.component'; + +const DEFAULT_CENTER = [51.505, -0.09]; + +export const MapModal = ({ + enrollment, + onUpdate, + setOpenMap, + defaultValues, + center, +}: MapModalProps) => { + const { geometryType, dataElementType } = useGeometry(enrollment); + + const onSetCoordinates = useCallback((coordinates) => { + const geometry = coordinates ? { type: geometryType, coordinates } : null; + onUpdate({ ...enrollment, geometry }); + }, [enrollment, geometryType, onUpdate]); + + return ( + + ); +}; diff --git a/src/core_modules/capture-core/components/WidgetEnrollment/MapModal/MapModal.types.js b/src/core_modules/capture-core/components/WidgetEnrollment/MapModal/MapModal.types.js new file mode 100644 index 0000000000..80a273420b --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetEnrollment/MapModal/MapModal.types.js @@ -0,0 +1,18 @@ +// @flow +import { dataElementTypes } from '../../../metaData'; + +export type MapModalComponentProps = { + center: ?[number, number], + type: typeof dataElementTypes.COORDINATE | typeof dataElementTypes.POLYGON, + defaultValues?: ?Array> | ?[number, number], + setOpen: (open: boolean) => void, + onSetCoordinates: (coordinates: ?[number, number] | ?Array<[number, number]>) => void, +} + +export type MapModalProps = {| + center?: ?[number, number], + enrollment: Object, + onUpdate: (arg: Object) => void, + setOpenMap: (toggle: boolean) => void, + defaultValues?: ?Array> | ?[number, number], +|}; diff --git a/src/core_modules/capture-core/components/WidgetEnrollment/MapModal/Polygon/ConditionalTooltip.component.js b/src/core_modules/capture-core/components/WidgetEnrollment/MapModal/Polygon/ConditionalTooltip.component.js new file mode 100644 index 0000000000..1250a792d2 --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetEnrollment/MapModal/Polygon/ConditionalTooltip.component.js @@ -0,0 +1,29 @@ +// @flow +import React from 'react'; +import { Tooltip } from '@dhis2/ui'; + +type Props = { + enabled: boolean, + children: any, +}; + +export const ConditionalTooltip = (props: Props) => { + const { enabled, children, ...passOnProps } = props; + + return enabled ? + ( + { ({ onMouseOver, onMouseOut, ref }) => ( + { + if (btnRef) { + btnRef.onpointerenter = onMouseOver; + btnRef.onpointerleave = onMouseOut; + ref.current = btnRef; + } + }} + > + {children} + + )} + ) : children; +}; diff --git a/src/core_modules/capture-core/components/WidgetEnrollment/MapModal/Polygon/DeleteControl.component.js b/src/core_modules/capture-core/components/WidgetEnrollment/MapModal/Polygon/DeleteControl.component.js new file mode 100644 index 0000000000..3dd18b0e96 --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetEnrollment/MapModal/Polygon/DeleteControl.component.js @@ -0,0 +1,52 @@ +// @flow +import React, { useEffect, useState, useCallback } from 'react'; +import ReactDOM from 'react-dom'; +import i18n from '@dhis2/d2-i18n'; +import classNames from 'classnames'; +import L, { Control } from 'leaflet'; +import { withLeaflet } from 'react-leaflet'; + +type Props = { + onClick: () => void, + disabled?: ?boolean, + leaflet: typeof Control, +}; + +const DeleteControlPlain = ({ onClick, disabled, leaflet }: Props) => { + const [leafletElement, setLeafletElement] = useState(); + const onHandleClick = useCallback(() => !disabled && onClick(), [disabled, onClick]); + + useEffect(() => { + const deleteControl = L.control({ position: 'topright' }); + const text = i18n.t('Delete polygon'); + const jsx = ( +
+ {/* eslint-disable-next-line */} + +
+ ); + + deleteControl.onAdd = () => { + const div = L.DomUtil.create('div', ''); + ReactDOM.render(jsx, div); + return div; + }; + setLeafletElement(deleteControl); + }, [onHandleClick, disabled]); + + useEffect(() => { + leafletElement && leafletElement.addTo(leaflet.map); + }, [leafletElement, leaflet.map]); + + useEffect(() => () => leafletElement && leafletElement.remove(), [leafletElement]); + + return null; +}; + +export const DeleteControl = withLeaflet(DeleteControlPlain); diff --git a/src/core_modules/capture-core/components/WidgetEnrollment/MapModal/Polygon/Polygon.component.js b/src/core_modules/capture-core/components/WidgetEnrollment/MapModal/Polygon/Polygon.component.js new file mode 100644 index 0000000000..d1c2eeefe1 --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetEnrollment/MapModal/Polygon/Polygon.component.js @@ -0,0 +1,225 @@ +// @flow +import React, { useState, useRef } from 'react'; +import i18n from '@dhis2/d2-i18n'; +import { Modal, ModalTitle, ModalContent, ModalActions, Button, ButtonStrip } from '@dhis2/ui'; +import { ReactLeafletSearch } from 'react-leaflet-search-unpolyfilled'; +import { Map, TileLayer, FeatureGroup, withLeaflet } from 'react-leaflet'; +import { EditControl } from 'react-leaflet-draw'; +import L from 'leaflet'; +import { withStyles } from '@material-ui/core'; +import type { PolygonProps, FeatureCollection } from './Polygon.types'; +import { convertPolygonToServer } from './converters'; +import { DeleteControl } from './DeleteControl.component'; +import { ConditionalTooltip } from './ConditionalTooltip.component'; + +const styles = () => ({ + modalContent: { + width: '100%', + }, + map: { + width: '100%', + height: 'calc(100vh - 380px)', + }, + setAreaButton: { + marginLeft: '5px', + }, +}); + +const coordsToFeatureCollection = (inputCoordinates: any): ?FeatureCollection => { + if (!inputCoordinates) { + return null; + } + const list = inputCoordinates[0].length > 2 ? inputCoordinates[0] : inputCoordinates.map(c => [c[1], c[0]]); + + return { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + properties: {}, + geometry: { + type: 'Polygon', + coordinates: [list], + }, + }, + ], + }; +}; + +const drawing = { + STARTED: 'STARTED', + FINISHED: 'FINISHED', +}; + +const WrappedLeafletSearch = withLeaflet(ReactLeafletSearch); + +const PolygonPlain = ({ + classes, + center: initialCenter, + setOpen, + defaultValues, + onSetCoordinates, +}: PolygonProps) => { + const [polygonArea, setPolygonArea] = useState(defaultValues); + const [center, setCenter] = useState(initialCenter); + const [drawingState, setDrawingState] = useState(undefined); + const prevDrawingState = useRef(undefined); + + const resetToDefaultValues = () => { + setCenter(initialCenter); + setPolygonArea(defaultValues); + }; + + const onMapPolygonCreated = (e: any) => { + const polygonCoordinates = e.layer.toGeoJSON().geometry.coordinates[0].map(c => [c[1], c[0]]); + setPolygonArea(polygonCoordinates); + setDrawingState(drawing.FINISHED); + prevDrawingState.current = drawing.FINISHED; + }; + + const onMapPolygonDelete = () => { + setPolygonArea(null); + setDrawingState(drawing.FINISHED); + prevDrawingState.current = drawing.FINISHED; + }; + + const onSearch = (searchPosition: any) => { + setCenter(searchPosition); + }; + + const getFeatureCollection = () => (Array.isArray(polygonArea) ? coordsToFeatureCollection(polygonArea) : null); + + const renderMap = () => ( + { + if (ref?.leafletElement) { + ref.leafletElement.invalidateSize(); + if (ref.contextValue && polygonArea) { + const { map } = ref.contextValue; + map?.fitBounds(polygonArea); + } + } + }} + className={classes.map} + > + + + { + onFeatureGroupReady(reactFGref, getFeatureCollection()); + }} + > + setDrawingState(drawing.STARTED)} + onDrawStop={() => setDrawingState(prevDrawingState.current)} + draw={{ + rectangle: false, + polyline: false, + circle: false, + marker: false, + circlemarker: false, + }} + edit={{ + remove: false, + edit: false, + }} + /> + + + + ); + + const onFeatureGroupReady = (reactFGref: any, featureCollection: ?FeatureCollection) => { + if (!reactFGref) { + return; + } + if (featureCollection) { + const leafletGeoJSON = new L.GeoJSON(featureCollection); + const leafletFG = reactFGref.leafletElement; + leafletFG.clearLayers(); + + leafletGeoJSON.eachLayer((layer) => { + leafletFG.addLayer(layer); + }); + } else { + const leafletFG = reactFGref.leafletElement; + leafletFG.clearLayers(); + } + }; + + const renderActions = () => ( + + {!drawingState && ( + + )} + {drawingState && ( + <> + + + + + + )} + + ); + + return ( + + {i18n.t('Area')} + {renderMap()} + {renderActions()} + + ); +}; + +export const Polygon = withStyles(styles)(PolygonPlain); diff --git a/src/core_modules/capture-core/components/WidgetEnrollment/MapModal/Polygon/Polygon.types.js b/src/core_modules/capture-core/components/WidgetEnrollment/MapModal/Polygon/Polygon.types.js new file mode 100644 index 0000000000..024be1b432 --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetEnrollment/MapModal/Polygon/Polygon.types.js @@ -0,0 +1,24 @@ +// @flow + +type Feature = { + type: string, + properties: Object, + geometry: { + type: string, + coordinates: Array | number>>, + }, +} + +export type FeatureCollection = { + type: string, + features: Array, +}; + +export type PolygonProps = { + center: ?[number, number], + setOpen: (open: boolean) => void, + onSetCoordinates: (coordinates: ?[number, number] | ?Array<[number, number]>) => void, + defaultValues?: ?Array>, + ...CssClasses, +} + diff --git a/src/core_modules/capture-core/components/WidgetEnrollment/MapModal/Polygon/converters.js b/src/core_modules/capture-core/components/WidgetEnrollment/MapModal/Polygon/converters.js new file mode 100644 index 0000000000..15556ee8c2 --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetEnrollment/MapModal/Polygon/converters.js @@ -0,0 +1,8 @@ +// @flow + +export const convertPolygonToServer = (coordinates?: Array> | null): ?Array<[number, number]> => { + if (!coordinates) { + return null; + } + return Array<[number, number]>(coordinates.map(c => (c ? [c[1], c[0]] : null))); +}; diff --git a/src/core_modules/capture-core/components/WidgetEnrollment/MapModal/Polygon/index.js b/src/core_modules/capture-core/components/WidgetEnrollment/MapModal/Polygon/index.js new file mode 100644 index 0000000000..04659c427b --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetEnrollment/MapModal/Polygon/index.js @@ -0,0 +1,3 @@ +// @flow +export { Polygon } from './Polygon.component'; + diff --git a/src/core_modules/capture-core/components/WidgetEnrollment/MapModal/index.js b/src/core_modules/capture-core/components/WidgetEnrollment/MapModal/index.js new file mode 100644 index 0000000000..41266d1ca4 --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetEnrollment/MapModal/index.js @@ -0,0 +1,3 @@ +// @flow +export { MapModal } from './MapModal.container'; + diff --git a/src/core_modules/capture-core/components/WidgetEnrollment/MiniMap/MiniMap.component.js b/src/core_modules/capture-core/components/WidgetEnrollment/MiniMap/MiniMap.component.js new file mode 100644 index 0000000000..a8d3304847 --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetEnrollment/MiniMap/MiniMap.component.js @@ -0,0 +1,80 @@ +// @flow +import React, { useState } from 'react'; +import { Map, TileLayer, Marker, Polygon } from 'react-leaflet'; +import { withStyles } from '@material-ui/core'; +import { dataElementTypes } from '../../../metaData'; +import { MapModal } from '../MapModal'; +import type { MiniMapProps } from './MiniMap.types'; +import { convertToClientCoordinates } from './converters'; +import { useUpdateEnrollment } from '../dataMutation/dataMutation'; + +const styles = () => ({ + mapContainer: { + width: 150, + height: 120, + }, + map: { + width: '100%', + height: '100%', + }, +}); + +const MiniMapPlain = ({ + coordinates, + geometryType, + enrollment, + refetchEnrollment, + refetchTEI, + onError, + classes, +}: MiniMapProps) => { + const [isOpenMap, setOpenMap] = useState(false); + const { updateMutation } = useUpdateEnrollment(refetchEnrollment, refetchTEI, onError); + const clientValues = convertToClientCoordinates(coordinates, geometryType); + const center = geometryType === dataElementTypes.COORDINATE ? clientValues : clientValues[0]; + const onMapReady = (mapRef) => { + if (mapRef?.contextValue && geometryType === dataElementTypes.POLYGON) { + const { map } = mapRef.contextValue; + map?.fitBounds(clientValues); + } + }; + + return ( + <> +
+ { + onMapReady(mapRef); + }} + center={center} + className={classes.map} + zoom={11} + zoomControl={false} + attributionControl={false} + key="minimap" + onClick={() => { + setOpenMap(true); + }} + > + + {geometryType === dataElementTypes.COORDINATE && } + {geometryType === dataElementTypes.POLYGON && } + +
+ {isOpenMap && ( + + )} + + ); +}; + +export const MiniMap = withStyles(styles)(MiniMapPlain); diff --git a/src/core_modules/capture-core/components/WidgetEnrollment/MiniMap/MiniMap.types.js b/src/core_modules/capture-core/components/WidgetEnrollment/MiniMap/MiniMap.types.js new file mode 100644 index 0000000000..b905c28783 --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetEnrollment/MiniMap/MiniMap.types.js @@ -0,0 +1,13 @@ +// @flow +import type { QueryRefetchFunction } from '@dhis2/app-runtime'; +import { dataElementTypes } from '../../../metaData'; + +export type MiniMapProps = { + coordinates: Array>, + enrollment: any, + refetchEnrollment: QueryRefetchFunction, + refetchTEI: QueryRefetchFunction, + onError?: (message: string) => void, + geometryType: typeof dataElementTypes.COORDINATE | typeof dataElementTypes.POLYGON, + ...CssClasses +} diff --git a/src/core_modules/capture-core/components/WidgetEnrollment/MiniMap/converters.js b/src/core_modules/capture-core/components/WidgetEnrollment/MiniMap/converters.js new file mode 100644 index 0000000000..498738ef13 --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetEnrollment/MiniMap/converters.js @@ -0,0 +1,10 @@ +// @flow +import { dataElementTypes } from '../../../metaData'; + +export const convertToClientCoordinates = (coordinates: any[], type: $Values) => { + if (type === dataElementTypes.COORDINATE) { + return [coordinates[1], coordinates[0]]; + } + + return coordinates[0].map(coord => [coord[1], coord[0]]); +}; diff --git a/src/core_modules/capture-core/components/WidgetEnrollment/MiniMap/index.js b/src/core_modules/capture-core/components/WidgetEnrollment/MiniMap/index.js new file mode 100644 index 0000000000..c1e7fa0e6f --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetEnrollment/MiniMap/index.js @@ -0,0 +1,2 @@ +// @flow +export { MiniMap } from './MiniMap.component'; diff --git a/src/core_modules/capture-core/components/WidgetEnrollment/WidgetEnrollment.component.js b/src/core_modules/capture-core/components/WidgetEnrollment/WidgetEnrollment.component.js index d9d400d044..018a6e7798 100644 --- a/src/core_modules/capture-core/components/WidgetEnrollment/WidgetEnrollment.component.js +++ b/src/core_modules/capture-core/components/WidgetEnrollment/WidgetEnrollment.component.js @@ -4,7 +4,6 @@ import moment from 'moment'; import { IconClock16, IconDimensionOrgUnit16, - IconLocation16, colors, Tag, spacersNum, @@ -16,12 +15,11 @@ import { LoadingMaskElementCenter } from '../LoadingMasks'; import { Widget } from '../Widget'; import type { PlainProps } from './enrollment.types'; import { Status } from './Status'; -import { convertValue as convertValueServerToClient } from '../../converters/serverToClient'; -import { convertValue as convertValueClientToView } from '../../converters/clientToView'; import { dataElementTypes } from '../../metaData'; import { useOrgUnitName } from '../../metadataRetrieval/orgUnitName'; import { Date } from './Date'; import { Actions } from './Actions'; +import { MiniMap } from './MiniMap'; const styles = { enrollment: { @@ -152,13 +150,14 @@ export const WidgetEnrollmentPlain = ({ {enrollment.geometry && (
- - - - {convertValueClientToView( - convertValueServerToClient(enrollment.geometry.coordinates, geometryType), - geometryType, - )} +
)} ({ + enrollments: [enrollment], + }), +}; + +const enrollmentDelete = { + resource: 'tracker?async=false&importStrategy=DELETE', + type: 'create', + data: enrollment => ({ + enrollments: [enrollment], + }), +}; + +const processErrorReports = (error) => { + // $FlowFixMe[prop-missing] + const errorReports = error?.details?.validationReport?.errorReports; + return errorReports?.length > 0 + ? errorReports.reduce((acc, errorReport) => `${acc} ${errorReport.message}`, '') + : error.message; +}; + + +export const useUpdateEnrollment = ( + refetchEnrollment: QueryRefetchFunction, + refetchTEI: QueryRefetchFunction, + onError?: ?(message: string) => void, +) => { + const [updateMutation, { loading: updateLoading }] = useDataMutation( + enrollmentUpdate, + { + onComplete: () => { + refetchEnrollment(); + refetchTEI(); + }, + onError: (e) => { + onError && onError(processErrorReports(e)); + }, + }, + ); + return { + updateMutation, updateLoading, + }; +}; + +export const useDeleteEnrollment = ( + onDelete: () => void, + onError?: ?(message: string) => void, +) => { + const [deleteMutation, { loading: deleteLoading }] = useDataMutation( + enrollmentDelete, + { + onComplete: onDelete, + onError: (e) => { + onError && onError(processErrorReports(e)); + }, + }, + ); + return { deleteMutation, deleteLoading }; +}; + diff --git a/src/core_modules/capture-core/components/WidgetEnrollment/hooks/useGeometry.js b/src/core_modules/capture-core/components/WidgetEnrollment/hooks/useGeometry.js new file mode 100644 index 0000000000..3569c211db --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetEnrollment/hooks/useGeometry.js @@ -0,0 +1,39 @@ +// @flow +import i18n from '@dhis2/d2-i18n'; +import { dataElementTypes } from '../../../metaData'; +import { useProgram } from './useProgram'; + +export const useGeometry = (enrollment: { program: string }) => { + const { + program: { featureType }, + } = useProgram(enrollment.program); + + if (featureType === 'POINT') { + return { + geometryType: 'Point', + dataElementType: dataElementTypes.COORDINATE, + }; + } + + return { + geometryType: 'Polygon', + dataElementType: dataElementTypes.POLYGON, + }; +}; + +export const useGeometryLabel = (enrollment: { program: string, geometry: { type: string } }) => { + const { + program: { featureType }, + error, + } = useProgram(enrollment.program); + + if (error || !featureType || !['POINT', 'POLYGON'].includes(featureType) || enrollment.geometry?.type) { + return undefined; + } + + if (featureType === 'POINT') { + return i18n.t('Add coordinates'); + } + + return i18n.t('Add area'); +}; diff --git a/src/core_modules/capture-core/components/WidgetEnrollment/hooks/useProgram.js b/src/core_modules/capture-core/components/WidgetEnrollment/hooks/useProgram.js index 4d643cb140..de41c3692e 100644 --- a/src/core_modules/capture-core/components/WidgetEnrollment/hooks/useProgram.js +++ b/src/core_modules/capture-core/components/WidgetEnrollment/hooks/useProgram.js @@ -10,7 +10,7 @@ export const useProgram = (programId: string) => { resource: `programs/${programId}`, params: { fields: [ - 'displayIncidentDate,displayIncidentDateLabel,displayEnrollmentDateLabel,onlyEnrollOnce,trackedEntityType[displayName],programStages[autoGenerateEvent],access', + 'displayIncidentDate,displayIncidentDateLabel,displayEnrollmentDateLabel,onlyEnrollOnce,trackedEntityType[displayName],programStages[autoGenerateEvent],access,featureType', ], }, }, @@ -18,5 +18,5 @@ export const useProgram = (programId: string) => { [programId], ), ); - return { error, program: !loading && data?.program }; + return { error, loading, program: data?.program }; };