Skip to content

Commit a9c69ab

Browse files
authored
add geoJSON input for Priority Bounds on challenge create/edit form (#2728)
* add geoJSON input for Priority Bounds on challenge create/edit form * Removed the old GeoJSON validation function and replaced it with checks using turf and geojson-validation libraries. Updated error handling * run formatting
1 parent d2ceccf commit a9c69ab

File tree

2 files changed

+207
-0
lines changed

2 files changed

+207
-0
lines changed

src/components/Custom/RJSFFormFieldAdapter/CustomPriorityBoundsField.jsx

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { getType } from "@turf/invariant";
2+
import { isFeature, isFeatureCollection } from "geojson-validation";
13
import L from "leaflet";
24
import { useEffect, useState } from "react";
35
import { FormattedMessage } from "react-intl";
@@ -116,6 +118,9 @@ const CustomPriorityBoundsField = (props) => {
116118
const [isMapVisible, setIsMapVisible] = useState(false);
117119
const [viewState, setViewState] = useState({ center: [0, 0], zoom: 2 });
118120
const [hasZoomed, setHasZoomed] = useState(false);
121+
const [uploadFeedback, setUploadFeedback] = useState(null);
122+
const [isDragOver, setIsDragOver] = useState(false);
123+
const [showTooltip, setShowTooltip] = useState(false);
119124

120125
// Determine priority type from field name
121126
const priorityType = props.name?.includes("highPriorityBounds")
@@ -169,12 +174,111 @@ const CustomPriorityBoundsField = (props) => {
169174
}
170175
}, [isMapVisible]);
171176

177+
// Close tooltip when clicking outside
178+
useEffect(() => {
179+
const handleClickOutside = (event) => {
180+
if (showTooltip && !event.target.closest(".tooltip-container")) {
181+
setShowTooltip(false);
182+
}
183+
};
184+
185+
document.addEventListener("click", handleClickOutside);
186+
return () => document.removeEventListener("click", handleClickOutside);
187+
}, [showTooltip]);
188+
172189
const handleChange = (newData) => {
173190
if (typeof props.onChange === "function") {
174191
props.onChange(Array.isArray(newData) ? [...newData] : newData);
175192
}
176193
};
177194

195+
// Handle file upload
196+
const handleFileUpload = async (file) => {
197+
setUploadFeedback(null);
198+
199+
const validExtensions = [".json", ".geojson"];
200+
const fileExtension = file.name.toLowerCase().substring(file.name.lastIndexOf("."));
201+
202+
if (!validExtensions.includes(fileExtension)) {
203+
setUploadFeedback({ type: "error", message: messages.fileTypeError });
204+
return;
205+
}
206+
207+
try {
208+
const text = await file.text();
209+
const geoJson = JSON.parse(text);
210+
211+
if (!isFeature(geoJson) && !isFeatureCollection(geoJson)) {
212+
throw new Error("Input must be a valid GeoJSON Feature or FeatureCollection");
213+
}
214+
215+
const features = getType(geoJson) === "Feature" ? [geoJson] : geoJson.features || [];
216+
217+
const polygonFeatures = features.filter(
218+
(feature) =>
219+
feature.type === "Feature" && feature.geometry && feature.geometry.type === "Polygon",
220+
);
221+
222+
if (polygonFeatures.length === 0) {
223+
throw new Error("No valid Polygon features found");
224+
}
225+
226+
const newData = [...formData, ...polygonFeatures];
227+
handleChange(newData);
228+
229+
setUploadFeedback({
230+
type: "success",
231+
message: messages.uploadSuccess,
232+
count: polygonFeatures.length,
233+
});
234+
235+
if (!isMapVisible) {
236+
setIsMapVisible(true);
237+
}
238+
239+
setHasZoomed(false);
240+
241+
setTimeout(() => setUploadFeedback(null), 3000);
242+
} catch (error) {
243+
setUploadFeedback({
244+
type: "error",
245+
message: messages.invalidGeoJSON,
246+
details: error.message,
247+
});
248+
}
249+
};
250+
251+
// Handle drag and drop
252+
const handleDragOver = (e) => {
253+
e.preventDefault();
254+
setIsDragOver(true);
255+
};
256+
257+
const handleDragLeave = (e) => {
258+
e.preventDefault();
259+
setIsDragOver(false);
260+
};
261+
262+
const handleDrop = (e) => {
263+
e.preventDefault();
264+
setIsDragOver(false);
265+
266+
const files = Array.from(e.dataTransfer.files);
267+
if (files.length > 0) {
268+
handleFileUpload(files[0]);
269+
}
270+
};
271+
272+
// Handle file input change
273+
const handleFileInputChange = (e) => {
274+
const files = Array.from(e.target.files);
275+
if (files.length > 0) {
276+
handleFileUpload(files[0]);
277+
}
278+
// Reset input value to allow same file to be selected again
279+
e.target.value = "";
280+
};
281+
178282
return (
179283
<div className="mr-relative mr-mb-4" onClick={(e) => e.stopPropagation()}>
180284
<div className="mr-flex mr-items-center mr-gap-2 mr-mb-2">
@@ -189,13 +293,90 @@ const CustomPriorityBoundsField = (props) => {
189293
<FormattedMessage {...(isMapVisible ? messages.hideMap : messages.showMap)} />
190294
</button>
191295

296+
{/* Upload GeoJSON Button */}
297+
<div
298+
className={`mr-button mr-button--small mr-flex mr-items-center mr-gap-2 mr-transition-all mr-duration-300 mr-mt-2 mr-py-2 mr-cursor-pointer ${
299+
isDragOver
300+
? "mr-button--green mr-bg-opacity-80"
301+
: "mr-button--white hover:mr-bg-gray-50"
302+
}`}
303+
onDragOver={handleDragOver}
304+
onDragLeave={handleDragLeave}
305+
onDrop={handleDrop}
306+
>
307+
<input
308+
type="file"
309+
accept=".json,.geojson"
310+
onChange={handleFileInputChange}
311+
className="mr-absolute mr-inset-0 mr-w-full mr-h-full mr-opacity-0 mr-cursor-pointer"
312+
id={`geojson-upload-${props.name}`}
313+
/>
314+
<SvgSymbol sym="upload-icon" viewBox="0 0 20 20" className="mr-w-5 mr-h-5" />
315+
<FormattedMessage {...messages.uploadGeoJSON} />
316+
</div>
317+
318+
{/* Info Icon */}
319+
<div className="mr-relative tooltip-container">
320+
<button
321+
type="button"
322+
onClick={(e) => {
323+
e.stopPropagation();
324+
setShowTooltip(!showTooltip);
325+
}}
326+
className="mr-ml-1 mr-mt-2 mr-p-1 mr-rounded-full mr-text-gray-500 hover:mr-text-gray-700 hover:mr-bg-gray-100 mr-transition-colors"
327+
title="Show GeoJSON format info"
328+
>
329+
<SvgSymbol sym="info-icon" viewBox="0 0 20 20" className="mr-w-4 mr-h-4" />
330+
</button>
331+
332+
{/* Custom Tooltip */}
333+
{showTooltip && (
334+
<div
335+
style={{ width: "300px" }}
336+
className="mr-absolute mr-z-50 mr-text-white mr-text-sm"
337+
>
338+
<pre className="mr-text-gray-300">
339+
<FormattedMessage {...messages.geoJSONFormatInfo} />
340+
</pre>
341+
</div>
342+
)}
343+
</div>
344+
192345
{formData.length > 0 && (
193346
<span className="mr-text-green-lighter mr-text-sm mr-font-medium">
194347
<FormattedMessage {...messages.polygonsDefined} values={{ count: formData.length }} />
195348
</span>
196349
)}
197350
</div>
198351

352+
{/* Upload Feedback */}
353+
{uploadFeedback && (
354+
<div
355+
className={`mr-mb-4 mr-p-2 mr-rounded mr-text-sm ${
356+
uploadFeedback.type === "success"
357+
? "mr-bg-green-lighter mr-bg-opacity-20 mr-text-green-darker mr-border mr-border-green-lighter"
358+
: "mr-bg-red-lighter mr-bg-opacity-20 mr-text-red-darker mr-border mr-border-red-lighter"
359+
}`}
360+
>
361+
{uploadFeedback.type === "success" ? (
362+
<FormattedMessage
363+
{...uploadFeedback.message}
364+
values={{ count: uploadFeedback.count }}
365+
/>
366+
) : (
367+
<div>
368+
<FormattedMessage
369+
{...uploadFeedback.message}
370+
values={{ error: uploadFeedback.details }}
371+
/>
372+
{uploadFeedback.details && (
373+
<div className="mr-text-xs mr-mt-1 mr-opacity-75">{uploadFeedback.details}</div>
374+
)}
375+
</div>
376+
)}
377+
</div>
378+
)}
379+
199380
{isMapVisible && (
200381
<div className="mr-relative mr-rounded-lg mr-overflow-hidden mr-shadow-lg mr-border mr-border-black-10 mr-transition-all mr-duration-300">
201382
<MapContainer

src/components/Custom/RJSFFormFieldAdapter/Messages.js

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,4 +85,30 @@ export default defineMessages({
8585
id: "CustomPriorityBoundsField.deletePolygon",
8686
defaultMessage: "Delete Polygon",
8787
},
88+
uploadGeoJSON: {
89+
id: "CustomPriorityBoundsField.uploadGeoJSON",
90+
defaultMessage: "Upload GeoJSON",
91+
},
92+
uploadSuccess: {
93+
id: "CustomPriorityBoundsField.uploadSuccess",
94+
defaultMessage: "Successfully uploaded {count} polygon{count, plural, one {} other {s}}",
95+
},
96+
uploadError: {
97+
id: "CustomPriorityBoundsField.uploadError",
98+
defaultMessage: "Upload failed: {error}",
99+
},
100+
invalidGeoJSON: {
101+
id: "CustomPriorityBoundsField.invalidGeoJSON",
102+
defaultMessage:
103+
"Invalid GeoJSON format. File must be a FeatureCollection with Polygon features.",
104+
},
105+
geoJSONFormatInfo: {
106+
id: "CustomPriorityBoundsField.geoJSONFormatInfo",
107+
defaultMessage:
108+
"Expects a GeoJSON Feature or FeatureCollection containing Polygon geometry(s).",
109+
},
110+
fileTypeError: {
111+
id: "CustomPriorityBoundsField.fileTypeError",
112+
defaultMessage: "Please select a .json or .geojson file",
113+
},
88114
});

0 commit comments

Comments
 (0)