Skip to content

Commit 1771ffc

Browse files
committed
add geoJSON input for Priority Bounds on challenge create/edit form
1 parent 8f495cd commit 1771ffc

2 files changed

Lines changed: 234 additions & 0 deletions

File tree

src/components/Custom/RJSFFormFieldAdapter/CustomPriorityBoundsField.jsx

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,9 @@ const CustomPriorityBoundsField = (props) => {
116116
const [isMapVisible, setIsMapVisible] = useState(false);
117117
const [viewState, setViewState] = useState({ center: [0, 0], zoom: 2 });
118118
const [hasZoomed, setHasZoomed] = useState(false);
119+
const [uploadFeedback, setUploadFeedback] = useState(null);
120+
const [isDragOver, setIsDragOver] = useState(false);
121+
const [showTooltip, setShowTooltip] = useState(false);
119122

120123
// Determine priority type from field name
121124
const priorityType = props.name?.includes("highPriorityBounds")
@@ -169,12 +172,140 @@ const CustomPriorityBoundsField = (props) => {
169172
}
170173
}, [isMapVisible]);
171174

175+
// Close tooltip when clicking outside
176+
useEffect(() => {
177+
const handleClickOutside = (event) => {
178+
if (showTooltip && !event.target.closest(".tooltip-container")) {
179+
setShowTooltip(false);
180+
}
181+
};
182+
183+
document.addEventListener("click", handleClickOutside);
184+
return () => document.removeEventListener("click", handleClickOutside);
185+
}, [showTooltip]);
186+
172187
const handleChange = (newData) => {
173188
if (typeof props.onChange === "function") {
174189
props.onChange(Array.isArray(newData) ? [...newData] : newData);
175190
}
176191
};
177192

193+
// Validate GeoJSON structure
194+
const validateGeoJSON = (geoJson) => {
195+
if (!geoJson || typeof geoJson !== "object") {
196+
return { valid: false, error: "Invalid JSON format" };
197+
}
198+
199+
if (!Array.isArray(geoJson.features)) {
200+
return { valid: false, error: "Features must be an array" };
201+
}
202+
203+
const polygonFeatures = geoJson.features.filter(
204+
(feature) =>
205+
feature.type === "Feature" && feature.geometry && feature.geometry.type === "Polygon",
206+
);
207+
208+
if (polygonFeatures.length === 0) {
209+
return { valid: false, error: "No valid Polygon features found" };
210+
}
211+
212+
return { valid: true, features: polygonFeatures };
213+
};
214+
215+
// Handle file upload
216+
const handleFileUpload = async (file) => {
217+
setUploadFeedback(null);
218+
219+
// Check file type
220+
const validExtensions = [".json", ".geojson"];
221+
const fileExtension = file.name.toLowerCase().substring(file.name.lastIndexOf("."));
222+
223+
if (!validExtensions.includes(fileExtension)) {
224+
setUploadFeedback({ type: "error", message: messages.fileTypeError });
225+
return;
226+
}
227+
228+
try {
229+
const text = await file.text();
230+
const geoJson = JSON.parse(text);
231+
232+
const validation = validateGeoJSON(geoJson);
233+
if (!validation.valid) {
234+
setUploadFeedback({
235+
type: "error",
236+
message: messages.invalidGeoJSON,
237+
details: validation.error,
238+
});
239+
return;
240+
}
241+
242+
// Convert GeoJSON features to the format expected by the component
243+
const convertedFeatures = validation.features.map((feature) => ({
244+
type: "Feature",
245+
geometry: feature.geometry,
246+
properties: feature.properties || {},
247+
}));
248+
249+
// Merge with existing data or replace
250+
const newData = [...formData, ...convertedFeatures];
251+
handleChange(newData);
252+
253+
setUploadFeedback({
254+
type: "success",
255+
message: messages.uploadSuccess,
256+
count: convertedFeatures.length,
257+
});
258+
259+
// Show map if not already visible
260+
if (!isMapVisible) {
261+
setIsMapVisible(true);
262+
}
263+
264+
// Reset zoom flag to trigger auto-zoom to new polygons
265+
setHasZoomed(false);
266+
267+
// Clear feedback after 3 seconds
268+
setTimeout(() => setUploadFeedback(null), 3000);
269+
} catch (error) {
270+
setUploadFeedback({
271+
type: "error",
272+
message: messages.uploadError,
273+
details: error.message,
274+
});
275+
}
276+
};
277+
278+
// Handle drag and drop
279+
const handleDragOver = (e) => {
280+
e.preventDefault();
281+
setIsDragOver(true);
282+
};
283+
284+
const handleDragLeave = (e) => {
285+
e.preventDefault();
286+
setIsDragOver(false);
287+
};
288+
289+
const handleDrop = (e) => {
290+
e.preventDefault();
291+
setIsDragOver(false);
292+
293+
const files = Array.from(e.dataTransfer.files);
294+
if (files.length > 0) {
295+
handleFileUpload(files[0]);
296+
}
297+
};
298+
299+
// Handle file input change
300+
const handleFileInputChange = (e) => {
301+
const files = Array.from(e.target.files);
302+
if (files.length > 0) {
303+
handleFileUpload(files[0]);
304+
}
305+
// Reset input value to allow same file to be selected again
306+
e.target.value = "";
307+
};
308+
178309
return (
179310
<div className="mr-relative mr-mb-4" onClick={(e) => e.stopPropagation()}>
180311
<div className="mr-flex mr-items-center mr-gap-2 mr-mb-2">
@@ -189,13 +320,91 @@ const CustomPriorityBoundsField = (props) => {
189320
<FormattedMessage {...(isMapVisible ? messages.hideMap : messages.showMap)} />
190321
</button>
191322

323+
{/* Upload GeoJSON Button */}
324+
<div
325+
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 ${
326+
isDragOver
327+
? "mr-button--green mr-bg-opacity-80"
328+
: "mr-button--white hover:mr-bg-gray-50"
329+
}`}
330+
onDragOver={handleDragOver}
331+
onDragLeave={handleDragLeave}
332+
onDrop={handleDrop}
333+
>
334+
<input
335+
type="file"
336+
accept=".json,.geojson"
337+
onChange={handleFileInputChange}
338+
className="mr-absolute mr-inset-0 mr-w-full mr-h-full mr-opacity-0 mr-cursor-pointer"
339+
id={`geojson-upload-${props.name}`}
340+
/>
341+
<SvgSymbol sym="upload-icon" viewBox="0 0 20 20" className="mr-w-5 mr-h-5" />
342+
<FormattedMessage {...messages.uploadGeoJSON} />
343+
</div>
344+
345+
{/* Info Icon */}
346+
<div className="mr-relative tooltip-container">
347+
<button
348+
type="button"
349+
onClick={(e) => {
350+
e.stopPropagation();
351+
setShowTooltip(!showTooltip);
352+
}}
353+
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"
354+
title="Show GeoJSON format info"
355+
>
356+
<SvgSymbol sym="info-icon" viewBox="0 0 20 20" className="mr-w-4 mr-h-4" />
357+
</button>
358+
359+
{/* Custom Tooltip */}
360+
{showTooltip && (
361+
<div
362+
style={{ width: "300px" }}
363+
className="mr-absolute mr-z-50 mr-text-white mr-text-sm"
364+
>
365+
<pre className="mr-text-gray-300">
366+
<FormattedMessage {...messages.geoJSONFormatInfo} />:
367+
{`\n{"features":[{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[lng,lat],[lng,lat],...]]}}]}`}
368+
</pre>
369+
</div>
370+
)}
371+
</div>
372+
192373
{formData.length > 0 && (
193374
<span className="mr-text-green-lighter mr-text-sm mr-font-medium">
194375
<FormattedMessage {...messages.polygonsDefined} values={{ count: formData.length }} />
195376
</span>
196377
)}
197378
</div>
198379

380+
{/* Upload Feedback */}
381+
{uploadFeedback && (
382+
<div
383+
className={`mr-mb-4 mr-p-2 mr-rounded mr-text-sm ${
384+
uploadFeedback.type === "success"
385+
? "mr-bg-green-lighter mr-bg-opacity-20 mr-text-green-darker mr-border mr-border-green-lighter"
386+
: "mr-bg-red-lighter mr-bg-opacity-20 mr-text-red-darker mr-border mr-border-red-lighter"
387+
}`}
388+
>
389+
{uploadFeedback.type === "success" ? (
390+
<FormattedMessage
391+
{...uploadFeedback.message}
392+
values={{ count: uploadFeedback.count }}
393+
/>
394+
) : (
395+
<div>
396+
<FormattedMessage
397+
{...uploadFeedback.message}
398+
values={{ error: uploadFeedback.details }}
399+
/>
400+
{uploadFeedback.details && (
401+
<div className="mr-text-xs mr-mt-1 mr-opacity-75">{uploadFeedback.details}</div>
402+
)}
403+
</div>
404+
)}
405+
</div>
406+
)}
407+
199408
{isMapVisible && (
200409
<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">
201410
<MapContainer

src/components/Custom/RJSFFormFieldAdapter/Messages.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,4 +85,29 @@ 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: "Example GeoJSON format",
108+
},
109+
fileTypeError: {
110+
id: "CustomPriorityBoundsField.fileTypeError",
111+
defaultMessage: "Please select a .json or .geojson file",
112+
},
88113
});

0 commit comments

Comments
 (0)