diff --git a/frontend/src/components/map/legend.tsx b/frontend/src/components/map/legend.tsx index 0f0ad4aa..631356af 100644 --- a/frontend/src/components/map/legend.tsx +++ b/frontend/src/components/map/legend.tsx @@ -22,7 +22,7 @@ const FillLegendStyle = ({ }; const Legend = () => { - const [expandLegend, setExpandLegend] = useState(true); + const [expandLegend, setExpandLegend] = useState(false); const { map } = useMap(); const activeLayers = map diff --git a/frontend/src/contents/toast-notifications.ts b/frontend/src/contents/toast-notifications.ts index c54155d3..b22391a3 100644 --- a/frontend/src/contents/toast-notifications.ts +++ b/frontend/src/contents/toast-notifications.ts @@ -10,6 +10,9 @@ export const TOAST_NOTIFICATIONS = { approvedPrediction: { success: "Saved successfully.", }, + resolved: { + success: "Action resolved successfully.", + }, modelPrediction: { success: "Model predictions retrieved successfully.", }, diff --git a/frontend/src/features/model-creation/components/training-area/training-area-item.tsx b/frontend/src/features/model-creation/components/training-area/training-area-item.tsx index 85b0b000..c71d24cf 100644 --- a/frontend/src/features/model-creation/components/training-area/training-area-item.tsx +++ b/frontend/src/features/model-creation/components/training-area/training-area-item.tsx @@ -228,9 +228,9 @@ const TrainingAreaItem: React.FC< ? "Fetching labels..." : trainingArea.properties.label_fetched !== null ? truncateString( - `Fetched ${timeSinceLabelFetch === "0 sec" ? "just now" : `${timeSinceLabelFetch} ago`}`, - 20, - ) + `Fetched ${timeSinceLabelFetch === "0 sec" ? "just now" : `${timeSinceLabelFetch} ago`}`, + 20, + ) : "No labels yet"}

diff --git a/frontend/src/features/start-mapping/api/create-feedbacks.ts b/frontend/src/features/start-mapping/api/create-feedbacks.ts index d28f0c60..e319f00c 100644 --- a/frontend/src/features/start-mapping/api/create-feedbacks.ts +++ b/frontend/src/features/start-mapping/api/create-feedbacks.ts @@ -17,7 +17,7 @@ export const createFeedback = async ({ source_imagery, zoom_level, training, -}: TCreateFeedbackPayload): Promise => { +}: TCreateFeedbackPayload): Promise => { return await ( await apiClient.post(API_ENDPOINTS.CREATE_FEEDBACK, { comments, @@ -42,7 +42,7 @@ export const createApprovedPrediction = async ({ geom, training, user, -}: TCreateApprovedPredictionPayload): Promise => { +}: TCreateApprovedPredictionPayload): Promise => { return await ( await apiClient.post(API_ENDPOINTS.CREATE_APPROVED_PREDICTION, { config, @@ -52,3 +52,29 @@ export const createApprovedPrediction = async ({ }) ).data; }; + +export type TDeleteModelPredictionFeedbackPayload = { + id: number; + approvePrediction?: boolean; +}; + +export const deleteModelPredictionFeedback = async ({ + id, +}: TDeleteModelPredictionFeedbackPayload) => { + return await ( + await apiClient.delete(API_ENDPOINTS.DELETE_FEEDBACK(id)) + ).data; +}; + +export type TDeleteApprovedModelPredictionPayload = { + id: number; + createFeedback?: boolean; +}; + +export const deleteApprovedModelPrediction = async ({ + id, +}: TDeleteApprovedModelPredictionPayload) => { + return await ( + await apiClient.delete(API_ENDPOINTS.DELETE_APPROVED_PREDICTION(id)) + ).data; +}; diff --git a/frontend/src/features/start-mapping/components/model-action.tsx b/frontend/src/features/start-mapping/components/model-action.tsx index 2dbfcac3..0f7d0265 100644 --- a/frontend/src/features/start-mapping/components/model-action.tsx +++ b/frontend/src/features/start-mapping/components/model-action.tsx @@ -1,4 +1,4 @@ -import { Feature, TModelPredictions } from "@/types"; +import { TModelPredictions } from "@/types"; import { MIN_ZOOM_LEVEL_FOR_PREDICTION, showErrorToast, diff --git a/frontend/src/features/start-mapping/components/popup.tsx b/frontend/src/features/start-mapping/components/popup.tsx index 86064f81..cad84dba 100644 --- a/frontend/src/features/start-mapping/components/popup.tsx +++ b/frontend/src/features/start-mapping/components/popup.tsx @@ -8,6 +8,8 @@ import { SHOELACE_SIZES } from "@/enums"; import { useCreateApprovedModelPrediction, useCreateModelFeedback, + useDeleteApprovedModelPrediction, + useDeleteModelPredictionFeedback, } from "@/features/start-mapping/hooks/use-feedbacks"; import { showErrorToast, showSuccessToast } from "@/utils"; import { geojsonToWKT } from "@terraformer/wkt"; @@ -43,34 +45,41 @@ const PredictedFeatureActionPopup = ({ const popupRef = useRef(null); const [popup, setPopup] = useState(null); const { accepted, rejected, all } = modelPredictions; + const { isMobile } = useScreenSize(); const alreadyAccepted = accepted.some( (feature) => feature.properties.id === featureId, ); const alreadyRejected = rejected.some( (feature) => feature.properties.id === featureId, ); + // if already accepted, it means it's in accepted array // if it's already rejected, it means it's in the rejected array // if it's not in accepted or rejected, then it's in the all array - const feature = alreadyAccepted - ? modelPredictions.accepted.filter( - (feature) => feature.properties.id === featureId, - )[0] - : alreadyRejected - ? modelPredictions.rejected.filter( - (feature) => feature.properties.id === featureId, - )[0] - : modelPredictions.all.filter( - (feature) => feature.properties.id === featureId, - )[0]; + const feature = + accepted.find((f) => f.properties.id === featureId) || + rejected.find((f) => f.properties.id === featureId) || + all.find((f) => f.properties.id === featureId); const [showComment, setShowComment] = useState(false); const [comment, setComment] = useState(""); - const moveFeature = (source: Feature[], target: Feature[], id: string) => { - const movedFeatures = source.filter( - (feature) => feature.properties.id === id, - ); + const moveFeature = ( + source: Feature[], + target: Feature[], + id: string, + additionalProperties: Partial = {}, + ) => { + const movedFeatures = source + .filter((feature) => feature.properties.id === id) + .map((feature) => ({ + ...feature, + properties: { + ...feature.properties, + ...additionalProperties, + }, + })); + return { updatedSource: source.filter((feature) => feature.properties.id !== id), updatedTarget: [...target, ...movedFeatures], @@ -93,40 +102,36 @@ const PredictedFeatureActionPopup = ({ const closePopup = () => { popup?.remove(); - // clean ups setShowComment(false); setComment(""); }; + const handleRejection = () => { + setShowComment(true); + }; + // Approved prediction is accept const createApprovedModelPredictionMutation = useCreateApprovedModelPrediction({ mutationConfig: { - onSuccess: () => { - if (alreadyRejected) { - const { updatedSource, updatedTarget } = moveFeature( - rejected, - accepted, - featureId, - ); - setModelPredictions((prev) => ({ - ...prev, - rejected: updatedSource, - accepted: updatedTarget, - })); - } else { - const { updatedSource, updatedTarget } = moveFeature( - all, - accepted, - featureId, - ); - setModelPredictions((prev) => ({ - ...prev, - all: updatedSource, - accepted: updatedTarget, - })); - } + onSuccess: (data) => { + const { updatedSource, updatedTarget } = alreadyRejected + ? moveFeature(rejected, accepted, featureId, { + _id: data.id, + ...data.properties, + }) + : moveFeature(all, accepted, featureId, { + _id: data.id, + ...data.properties, + }); + + setModelPredictions((prev) => ({ + ...prev, + rejected: alreadyRejected ? updatedSource : prev.rejected, + all: alreadyRejected ? prev.all : updatedSource, + accepted: updatedTarget, + })); closePopup(); showSuccessToast( TOAST_NOTIFICATIONS.startMapping.approvedPrediction.success, @@ -138,15 +143,97 @@ const PredictedFeatureActionPopup = ({ }, }); + const deleteModelFeedbackMutation = useDeleteModelPredictionFeedback({ + mutationConfig: { + onSuccess: (_, variables) => { + if (variables.approvePrediction) { + submitApprovedPrediction(); + } else { + const { updatedSource: updatedRejected } = moveFeature( + rejected, + all, + featureId, + ); + setModelPredictions((prev) => ({ + ...prev, + all: [ + ...all, + ...rejected.filter((f) => f.properties.id === featureId), + ], + rejected: updatedRejected, + })); + } + showSuccessToast(TOAST_NOTIFICATIONS.startMapping.resolved.success); + }, + onError: (error) => { + showErrorToast(error); + }, + }, + }); + + const deleteApprovedModelPrediction = useDeleteApprovedModelPrediction({ + mutationConfig: { + onSuccess: async (_, variables) => { + if (variables.createFeedback) { + await createModelFeedbackMutation.mutateAsync({ + zoom_level: trainingConfig.zoom_level, + comments: comment, + geom: geojsonToWKT(feature?.geometry as GeoJSONType), + feedback_type: "TN", + source_imagery: source_imagery, + training: trainingId, + }); + } else { + const { updatedSource: updatedAccepted } = moveFeature( + accepted, + all, + featureId, + ); + setModelPredictions((prev) => ({ + ...prev, + all: [ + ...all, + ...accepted.filter((f) => f.properties.id === featureId), + ], + accepted: updatedAccepted, + })); + } + showSuccessToast(TOAST_NOTIFICATIONS.startMapping.resolved.success); + }, + onError: (error) => { + showErrorToast(error); + }, + }, + }); + + const submitApprovedPrediction = async () => { + await createApprovedModelPredictionMutation.mutateAsync({ + geom: geojsonToWKT(feature?.geometry as GeoJSONType), + training: trainingId, + config: { + areathreshold: Number(trainingConfig.area_threshold), + confidence: trainingConfig.confidence, + josmq: trainingConfig.use_josm_q, + maxanglechange: trainingConfig.max_angle_change, + skewtolerance: trainingConfig.skew_tolerance, + tolerance: trainingConfig.tolerance, + zoomlevel: trainingConfig.zoom_level, + }, + user: user.osm_id, + }); + }; + // Rejection is the same as feedback const createModelFeedbackMutation = useCreateModelFeedback({ mutationConfig: { - onSuccess: () => { + onSuccess: (data) => { if (alreadyAccepted) { const { updatedSource, updatedTarget } = moveFeature( accepted, rejected, featureId, + // update the feature with the returned id from the backend as `_id` and other properties from the backend. + { _id: data.id, ...data.properties }, ); setModelPredictions((prev) => ({ ...prev, @@ -158,6 +245,8 @@ const PredictedFeatureActionPopup = ({ all, rejected, featureId, + // update the feature with the returned id from the backend as `_id` and other properties from the backend. + { _id: data.id, ...data.properties }, ); setModelPredictions((prev) => ({ ...prev, @@ -175,60 +264,45 @@ const PredictedFeatureActionPopup = ({ }); const submitRejectionFeedback = async () => { - await createModelFeedbackMutation.mutateAsync({ - zoom_level: trainingConfig.zoom_level, - comments: comment, - geom: geojsonToWKT(feature.geometry as GeoJSONType), - feedback_type: "TN", - source_imagery: source_imagery, - training: trainingId, - }); - }; - - const handleRejection = () => { - setShowComment(true); + if (alreadyAccepted) { + await deleteApprovedModelPrediction.mutateAsync({ + id: feature?.properties._id, + createFeedback: true, + }); + } else { + await createModelFeedbackMutation.mutateAsync({ + zoom_level: trainingConfig.zoom_level, + comments: comment, + geom: geojsonToWKT(feature?.geometry as GeoJSONType), + feedback_type: "TN", + source_imagery: source_imagery, + training: trainingId, + }); + } }; - const handleResolve = () => { - const { updatedSource: updatedRejected } = moveFeature( - rejected, - all, - featureId, - ); - const { updatedSource: updatedAccepted } = moveFeature( - accepted, - all, - featureId, - ); - setModelPredictions((prev) => ({ - ...prev, - all: [ - ...all, - ...rejected.filter((f) => f.properties.id === featureId), - ...accepted.filter((f) => f.properties.id === featureId), - ], - rejected: updatedRejected, - accepted: updatedAccepted, - })); - + const handleResolve = async () => { + if (alreadyRejected) { + await deleteModelFeedbackMutation.mutateAsync({ + id: feature?.properties._id, + }); + } else if (alreadyAccepted) { + await deleteApprovedModelPrediction.mutateAsync({ + id: feature?.properties._id, + }); + } closePopup(); }; const handleAcceptance = async () => { - await createApprovedModelPredictionMutation.mutateAsync({ - geom: geojsonToWKT(feature.geometry as GeoJSONType), - training: trainingId, - config: { - areathreshold: Number(trainingConfig.area_threshold), - confidence: trainingConfig.confidence, - josmq: trainingConfig.use_josm_q, - maxanglechange: trainingConfig.max_angle_change, - skewtolerance: trainingConfig.skew_tolerance, - tolerance: trainingConfig.tolerance, - zoomlevel: trainingConfig.zoom_level, - }, - user: user.osm_id, - }); + if (alreadyRejected) { + await deleteModelFeedbackMutation.mutateAsync({ + id: feature?.properties._id, + approvePrediction: true, + }); + } else { + submitApprovedPrediction(); + } }; const primaryButton = alreadyAccepted @@ -272,7 +346,7 @@ const PredictedFeatureActionPopup = ({ className: "bg-primary", icon: RejectIcon, }; - const { isMobile } = useScreenSize(); + return (