diff --git a/api/src/paths/project/list.test.ts b/api/src/paths/project/list.test.ts index a16f575a9b..a730991df9 100644 --- a/api/src/paths/project/list.test.ts +++ b/api/src/paths/project/list.test.ts @@ -142,7 +142,7 @@ describe('list', () => { const requestHandler = list.getProjectList(); await requestHandler(sampleReq, sampleRes as any, null as unknown as any); - expect.fail(); + expect.fail('Expected an error to be thrown'); } catch (actualError) { expect(dbConnectionObj.release).to.have.been.called; diff --git a/app/src/components/map/components/DrawControls.tsx b/app/src/components/map/components/DrawControls.tsx index ab0d0f4839..6c0d8cce37 100644 --- a/app/src/components/map/components/DrawControls.tsx +++ b/app/src/components/map/components/DrawControls.tsx @@ -51,11 +51,22 @@ export interface IDrawControlsRef { /** * Adds a GeoJson feature to a new layer in the draw controls layer group. * - * @memberof IDrawControlsRef + * @param feature The GeoJson feature to add as a layer. + * @param layerId A function to receive the unique ID assigned to the added layer. */ addLayer: (feature: Feature, layerId: (id: number) => void) => void; + /** + * Deletes a layer from the draw controls layer group by its unique ID. + * + * @param layerId The unique ID of the layer to delete. + */ deleteLayer: (layerId: number) => void; + + /** + * Clears all layers from the draw controls layer group. + */ + clearLayers: () => void; } /** @@ -196,6 +207,10 @@ const DrawControls = forwardRef { const featureGroup = getFeatureGroup(); featureGroup.removeLayer(layerId); + }, + clearLayers: () => { + const featureGroup = getFeatureGroup(); + featureGroup.clearLayers(); } }), [getFeatureGroup] diff --git a/app/src/components/map/components/ImportBoundaryDialog.tsx b/app/src/components/map/components/ImportBoundaryDialog.tsx index 3e5af172b3..99b1a5f2ab 100644 --- a/app/src/components/map/components/ImportBoundaryDialog.tsx +++ b/app/src/components/map/components/ImportBoundaryDialog.tsx @@ -6,6 +6,7 @@ import { Feature } from 'geojson'; import { boundaryUploadHelper } from 'utils/mapBoundaryUploadHelpers'; export interface IImportBoundaryDialogProps { + dialogTitle: string; isOpen: boolean; onClose: () => void; onSuccess: (features: Feature[]) => void; @@ -13,9 +14,9 @@ export interface IImportBoundaryDialogProps { } const ImportBoundaryDialog = (props: IImportBoundaryDialogProps) => { - const { isOpen, onClose, onSuccess, onFailure } = props; + const { dialogTitle, isOpen, onClose, onSuccess, onFailure } = props; return ( - + diff --git a/app/src/features/surveys/animals/profile/captures/capture-form/components/AnimalCaptureForm.tsx b/app/src/features/surveys/animals/profile/captures/capture-form/components/AnimalCaptureForm.tsx index eff71ba31c..ea1baae3ce 100644 --- a/app/src/features/surveys/animals/profile/captures/capture-form/components/AnimalCaptureForm.tsx +++ b/app/src/features/surveys/animals/profile/captures/capture-form/components/AnimalCaptureForm.tsx @@ -44,7 +44,13 @@ export const AnimalCaptureForm = (false); const [lastDrawn, setLastDrawn] = useState(null); + const { values, setFieldValue, setFieldError, errors } = useFormikContext(); + const drawControlsRef = useRef(); const { mapId } = props; - const { values, setFieldValue, setFieldError, errors } = useFormikContext(); - - const [updatedBounds, setUpdatedBounds] = useState(undefined); - - // Array of capture location features - const captureLocationGeoJson: Feature | undefined = useMemo(() => { - const location: { latitude: number; longitude: number } | Feature = get(values, name); + // Define location as a GeoJson object using useMemo to memoize the value + const captureLocationGeoJson: Feature | undefined = useMemo(() => { + const location: { latitude: number; longitude: number } | Feature | undefined | null = get(values, name); if (!location) { return; } - if ('latitude' in location && location.latitude !== 0 && location.longitude !== 0) { + if ( + 'latitude' in location && + 'longitude' in location && + isValidCoordinates(location.latitude, location.longitude) + ) { return { type: 'Feature', geometry: { type: 'Point', coordinates: [location.longitude, location.latitude] }, @@ -71,36 +75,85 @@ export const CaptureLocationMapControl = { - setUpdatedBounds(calculateUpdatedMapBounds([ALL_OF_BC_BOUNDARY])); + const coordinates = captureLocationGeoJson && getCoordinatesFromGeoJson(captureLocationGeoJson); + + // Initialize state based on formik context for the edit page + const [latitudeInput, setLatitudeInput] = useState(coordinates ? String(coordinates.latitude) : ''); + const [longitudeInput, setLongitudeInput] = useState(coordinates ? String(coordinates.longitude) : ''); + + const [updatedBounds, setUpdatedBounds] = useState(undefined); + // Update map bounds when the data changes + useEffect(() => { if (captureLocationGeoJson) { - if ('type' in captureLocationGeoJson) { - if (captureLocationGeoJson.geometry.type === 'Point') - if (captureLocationGeoJson?.geometry.coordinates[0] !== 0) { - setUpdatedBounds(calculateUpdatedMapBounds([captureLocationGeoJson])); - } + const { latitude, longitude } = getCoordinatesFromGeoJson(captureLocationGeoJson); + + if (isValidCoordinates(latitude, longitude)) { + setUpdatedBounds(calculateUpdatedMapBounds([captureLocationGeoJson])); } + } else { + // If the capture location is not a valid point, set the bounds to the entire province + setUpdatedBounds(calculateUpdatedMapBounds([ALL_OF_BC_BOUNDARY])); } }, [captureLocationGeoJson]); + const updateFormikLocationFromLatLon = useMemo( + () => + debounce((name, feature) => { + setFieldValue(name, feature); + }, 500), + [setFieldValue] + ); + + // Update formik and map when latitude/longitude text inputs change + useEffect(() => { + const lat = latitudeInput && parseFloat(latitudeInput); + const lon = longitudeInput && parseFloat(longitudeInput); + + if (!(lat && lon)) { + setFieldValue(name, undefined); + return; + } + + // If coordinates are invalid, reset the map to show nothing + if (!isValidCoordinates(lat, lon)) { + drawControlsRef.current?.clearLayers(); + setUpdatedBounds(calculateUpdatedMapBounds([ALL_OF_BC_BOUNDARY])); + return; + } + + const feature: Feature = { + id: 1, + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [lon, lat] + }, + properties: {} + }; + + // Update formik through debounce function + updateFormikLocationFromLatLon(name, feature); + }, [latitudeInput, longitudeInput, name, setFieldValue, updateFormikLocationFromLatLon]); + return ( - {get(errors, name) && !Array.isArray(get(errors, name)) && ( + {typeof get(errors, name) === 'string' && !Array.isArray(get(errors, name)) && ( Missing capture location - {get(errors, name) as string} + {get(errors, name)} )} setIsOpen(false)} onSuccess={(features) => { @@ -108,15 +161,16 @@ export const CaptureLocationMapControl = 1); - setLastDrawn(1); + if ('coordinates' in features[0].geometry) { + setLatitudeInput(String(features[0].geometry.coordinates[1])); + setLongitudeInput(String(features[0].geometry.coordinates[0])); + } }} onFailure={(message) => { setFieldError(name, message); }} /> - {title} + { + setLatitudeInput(event.currentTarget.value ?? ''); + }} + onLongitudeChange={(event) => { + setLongitudeInput(event.currentTarget.value ?? ''); + }} + /> - + , string | undefined>; } - export class ArraySchema extends yup.ArraySchema { + interface ArraySchema { /** * Determine if the array of classification details has duplicates * @@ -160,5 +160,16 @@ declare module 'yup' { startKey: string, endKey: string ): yup.StringSchema, string | undefined>; + + /** + * Validates that the GeoJson point coordinates (latitude/longitude) are valid + * + * @param {string} message Error message to display + * @return {*} {(yup.NumberSchema, number | undefined>)} + * @memberof ArraySchema + */ + isValidPointCoordinates( + message: string + ): yup.NumberSchema, number | undefined>; } } diff --git a/app/src/utils/Utils.tsx b/app/src/utils/Utils.tsx index 0bf55f09a3..16e635759d 100644 --- a/app/src/utils/Utils.tsx +++ b/app/src/utils/Utils.tsx @@ -412,8 +412,6 @@ export const shapeFileFeatureDesc = (geometry: Feature { + if (!value || !value.length) { + return false; + } + return isValidCoordinates(value[1], value[0]); + }); +}); + export default yup; diff --git a/app/src/utils/spatial-utils.ts b/app/src/utils/spatial-utils.ts index 4d4104a325..9cce878245 100644 --- a/app/src/utils/spatial-utils.ts +++ b/app/src/utils/spatial-utils.ts @@ -1,4 +1,4 @@ -import { Feature } from 'geojson'; +import { Feature, Point } from 'geojson'; import { isDefined } from 'utils/Utils'; /** @@ -33,3 +33,37 @@ export const getPointFeature = (param properties: { ...params.properties } }; }; + +/** + * Checks whether a latitude-longitude pair of coordinates is valid + * + * @param {number} latitude + * @param {number} longitude + * @returns boolean + */ +export const isValidCoordinates = (latitude: number | undefined, longitude: number | undefined) => { + return latitude && longitude && latitude > -90 && latitude < 90 && longitude > -180 && longitude < 180 ? true : false; +}; + +/** + * Gets latitude and longitude values from a GeoJson Point Feature. + * + * @param {Feature} feature + * @return {*} {{ latitude: number; longitude: number }} + */ +export const getCoordinatesFromGeoJson = (feature: Feature): { latitude: number; longitude: number } => { + const lon = feature.geometry.coordinates[0]; + const lat = feature.geometry.coordinates[1]; + + return { latitude: lat as number, longitude: lon as number }; +}; + +/** + * Checks if the given feature is a GeoJson Feature containing a Point. + * + * @param {(Feature | any)} [feature] + * @return {*} {feature is Feature} + */ +export const isGeoJsonPointFeature = (feature?: Feature | any): feature is Feature => { + return (feature as Feature)?.geometry.type === 'Point'; +};