diff --git a/frontend/src/components/shared/modals/confirmation-modal.tsx b/frontend/src/components/shared/modals/confirmation-modal.tsx new file mode 100644 index 00000000..9d277ee6 --- /dev/null +++ b/frontend/src/components/shared/modals/confirmation-modal.tsx @@ -0,0 +1,57 @@ +import { Button } from "@/components/ui/button"; +import { Dialog } from "@/components/ui/dialog"; +import { ButtonVariant, SHOELACE_SIZES } from "@/enums"; +import useScreenSize from "@/hooks/use-screen-size"; + +type ConfirmationModalProps = { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + message: string; + icon: React.ReactNode; + isConfirming?: boolean; + confirmLabel?: string; + cancelLabel?: string; +}; + +export const ConfirmationModal = ({ + isOpen, + onClose, + onConfirm, + message, + icon, + isConfirming = false, + confirmLabel = "Confirm", + cancelLabel = "Cancel", +}: ConfirmationModalProps) => { + const { isMobile } = useScreenSize(); + + if (!isOpen) return null; + + return ( + + + {icon} + {message} + + + {cancelLabel} + + + {confirmLabel} + + + + + ); +}; diff --git a/frontend/src/components/shared/modals/index.ts b/frontend/src/components/shared/modals/index.ts index c4a5380d..4f989826 100644 --- a/frontend/src/components/shared/modals/index.ts +++ b/frontend/src/components/shared/modals/index.ts @@ -1 +1,3 @@ export { DeleteModal } from "./delete-modal"; +export { ConfirmationModal } from "./confirmation-modal"; +export { SuccessModal } from "./success-modal"; diff --git a/frontend/src/components/shared/modals/success-modal.tsx b/frontend/src/components/shared/modals/success-modal.tsx new file mode 100644 index 00000000..ed3e2fa6 --- /dev/null +++ b/frontend/src/components/shared/modals/success-modal.tsx @@ -0,0 +1,41 @@ +import { Button } from "@/components/ui/button"; +import { Dialog } from "@/components/ui/dialog"; +import { SHOELACE_SIZES } from "@/enums"; +import useScreenSize from "@/hooks/use-screen-size"; + +type SuccessModalProps = { + isOpen: boolean; + onClose: () => void; + message: string; + icon: React.ReactNode; + closeLabel?: string; +}; + +export const SuccessModal = ({ + isOpen, + onClose, + message, + icon, + closeLabel = "Done", +}: SuccessModalProps) => { + const { isMobile } = useScreenSize(); + + if (!isOpen) return null; + + return ( + + + {icon} + {message} + + {closeLabel} + + + + ); +}; diff --git a/frontend/src/components/shared/training-status-badge.tsx b/frontend/src/components/shared/training-status-badge.tsx index e6c933d4..c4b8411e 100644 --- a/frontend/src/components/shared/training-status-badge.tsx +++ b/frontend/src/components/shared/training-status-badge.tsx @@ -8,6 +8,7 @@ export const TrainingStatusBadge = ({ status }: { status: string }) => { submitted: "blue", running: "yellow", pending: "yellow", + published: "green", }; return ( diff --git a/frontend/src/components/ui/icons/warning-icon.tsx b/frontend/src/components/ui/icons/warning-icon.tsx new file mode 100644 index 00000000..ee669b7c --- /dev/null +++ b/frontend/src/components/ui/icons/warning-icon.tsx @@ -0,0 +1,18 @@ +import { IconProps, ShoelaceSlotProps } from "@/types"; +import React from "react"; + +export const WarningIcon: React.FC = (props) => ( + + + +); diff --git a/frontend/src/components/ui/icons/wavy-check-icon.tsx b/frontend/src/components/ui/icons/wavy-check-icon.tsx new file mode 100644 index 00000000..46346a63 --- /dev/null +++ b/frontend/src/components/ui/icons/wavy-check-icon.tsx @@ -0,0 +1,20 @@ +import { IconProps, ShoelaceSlotProps } from "@/types"; +import React from "react"; + +export const WavyCheckIcon: React.FC = ( + props, +) => ( + + + +); diff --git a/frontend/src/features/user-profile/api/predictions.ts b/frontend/src/features/user-profile/api/predictions.ts index 8780b8a5..b734731d 100644 --- a/frontend/src/features/user-profile/api/predictions.ts +++ b/frontend/src/features/user-profile/api/predictions.ts @@ -55,3 +55,36 @@ export const useRetryOfflinePrediction = ({ mutationFn: retryOfflinePrediction, }); }; + +export const publishPrediction = ({ + predictionId, + published, +}: { + predictionId: number; + published: boolean; +}) => { + return apiClient.patch( + API_ENDPOINTS.PUBLISH_OFFLINE_PREDICTION(predictionId), + { published }, + ); +}; + +type TUsePublishPredictionOptions = { + mutationConfig?: MutationConfig; +}; +export const usePublishPrediction = ({ + mutationConfig, +}: TUsePublishPredictionOptions) => { + const queryClient = useQueryClient(); + const { onSuccess, ...restConfig } = mutationConfig || {}; + return useMutation({ + onSuccess: async (...args) => { + onSuccess?.(...args); + queryClient.invalidateQueries({ + queryKey: ["offline-predictions"], + }); + }, + ...restConfig, + mutationFn: publishPrediction, + }); +}; diff --git a/frontend/src/features/user-profile/components/offline-predictions/offline-prediction-card.tsx b/frontend/src/features/user-profile/components/offline-predictions/offline-prediction-card.tsx index 93a68d64..93a98a2e 100644 --- a/frontend/src/features/user-profile/components/offline-predictions/offline-prediction-card.tsx +++ b/frontend/src/features/user-profile/components/offline-predictions/offline-prediction-card.tsx @@ -6,6 +6,7 @@ import { formatDate, formatDuration, formatNumber } from "@/utils"; import { OfflinePredictionActions } from "./offline-predictions-actions"; import { MapIcon } from "@/components/ui/icons"; import { MapSwipeProjectIsActive } from "./mapswipe-project-active"; +import { getDisplayStatus } from "@/features/user-profile/utils/get-display-status"; export const OfflinePredictionCard = ({ predictionResult, @@ -41,7 +42,13 @@ export const OfflinePredictionCard = ({ } /> - + + { const { copyToClipboard } = useCopyToClipboard(); const { dropdownRef } = useDropdownMenu(); + const [isPublishFlowOpen, setIsPublishFlowOpen] = useState(false); const handleSettingsInfo = () => { if (dropdownRef?.current) { dropdownRef.current.show(); } }; + const { mutate: terminationMutation } = useTerminateOfflinePrediction({ mutationConfig: { onSuccess: (data) => { @@ -60,8 +64,20 @@ export const OfflinePredictionActions = ({ }, predictionId: Number(predictionResult.id), }); + // Extracted isFinished and hasResults for better readability and reusablity + const isFinished = predictionResult.status === ModelTrainingStatus.FINISHED; + const hasResults = (predictionResult?.result?.count ?? 0) > 0; + const canPublishOrRetract = isFinished && hasResults; + return ( <> + setIsPublishFlowOpen(false)} + /> + void }) => { + e.stopPropagation(); + setIsPublishFlowOpen(true); + }, + }, + ] + : []), ]} /> > diff --git a/frontend/src/features/user-profile/components/offline-predictions/offline-predictions-table.tsx b/frontend/src/features/user-profile/components/offline-predictions/offline-predictions-table.tsx index a75c51e2..f3bc49bc 100644 --- a/frontend/src/features/user-profile/components/offline-predictions/offline-predictions-table.tsx +++ b/frontend/src/features/user-profile/components/offline-predictions/offline-predictions-table.tsx @@ -16,7 +16,7 @@ import { OfflinePredictionsSettingsInfo } from "./offline-predictions-settings-i import { OfflinePredictionActions } from "./offline-predictions-actions"; import { ToolTip } from "@/components/ui/tooltip"; import { MapSwipeProjectIsActive } from "./mapswipe-project-active"; - +import { getDisplayStatus } from "@/features/user-profile/utils/get-display-status"; type OfflinePredictionsTableProps = { data: TOfflinePrediction[]; isError: boolean; @@ -69,8 +69,15 @@ const columnDefinitions = ( { header: "Status", accessorKey: "status", - cell: (row) => , + cell: ({ row }) => { + const displayStatus = getDisplayStatus( + row.original.status, + row.original.published, + ); + return ; + }, }, + { header: "Duration", accessorFn: (row) => diff --git a/frontend/src/features/user-profile/components/offline-predictions/publish-prediction-flow.tsx b/frontend/src/features/user-profile/components/offline-predictions/publish-prediction-flow.tsx new file mode 100644 index 00000000..11346bfa --- /dev/null +++ b/frontend/src/features/user-profile/components/offline-predictions/publish-prediction-flow.tsx @@ -0,0 +1,96 @@ +import { ConfirmationModal, SuccessModal } from "@/components/shared/modals"; +import { WarningIcon } from "@/components/ui/icons/warning-icon"; +import { WavyCheckIcon } from "@/components/ui/icons/wavy-check-icon"; +import { usePublishPrediction } from "@/features/user-profile/api/predictions"; +import { showErrorToast } from "@/utils"; +import { useState } from "react"; + +type ModalStep = "confirming" | "success"; + +type PublishAction = "publish" | "unpublish"; + +type PublishPredictionFlowProps = { + predictionId: number; + isPublished: boolean; + isOpen: boolean; + onClose: () => void; +}; + +export const PublishPredictionFlow = ({ + predictionId, + isPublished, + isOpen, + onClose, +}: PublishPredictionFlowProps) => { + const [step, setStep] = useState("confirming"); + /* + stored the action locally because `isPublished` from props may still contain + the previous value when the success modal renders after the mutation. This + prevents the UI from briefly showing the wrong success message. +*/ + const [action, setAction] = useState(null); + + const { mutate, isPending } = usePublishPrediction({ + mutationConfig: { + onSuccess: () => setStep("success"), + onError: (err) => { + onClose(); + showErrorToast(err); + }, + }, + }); + + const handleClose = () => { + setStep("confirming"); + setAction(null); + onClose(); + }; + + const handleConfirm = () => { + const nextAction: PublishAction = isPublished ? "unpublish" : "publish"; + setAction(nextAction); + mutate({ + predictionId, + published: !isPublished, + }); + }; + + if (!isOpen) return null; + if (step === "success") { + return ( + + + + } + /> + ); + } + + return ( + + + + } + /> + ); +}; diff --git a/frontend/src/features/user-profile/utils/get-display-status.ts b/frontend/src/features/user-profile/utils/get-display-status.ts new file mode 100644 index 00000000..07dfeef1 --- /dev/null +++ b/frontend/src/features/user-profile/utils/get-display-status.ts @@ -0,0 +1,18 @@ +import { ModelTrainingStatus } from "@/enums"; + +/** + * Determines the display status of a prediction. + * + * When a prediction is finished AND published, the display status should + * be "PUBLISHED" instead of the underlying API status. For all other cases + * (for other statuses), return the API status as-is. + */ +export const getDisplayStatus = ( + status: ModelTrainingStatus, + published: boolean, +): string => { + if (status === ModelTrainingStatus.FINISHED && published) { + return "PUBLISHED"; + } + return status; +}; diff --git a/frontend/src/services/api-routes.ts b/frontend/src/services/api-routes.ts index ac9fc42b..34234467 100644 --- a/frontend/src/services/api-routes.ts +++ b/frontend/src/services/api-routes.ts @@ -107,6 +107,7 @@ export const API_ENDPOINTS = { `workspace/download/training_${trainingId}/${directory_name}/`, TERMINATE_OFFLINE_PREDICTION: (id: number) => `prediction/terminate/${id}/`, RETRY_OFFLINE_PREDICTION: (id: number) => `prediction/${id}/retry/`, + PUBLISH_OFFLINE_PREDICTION: (id: number) => `prediction/${id}/`, // Notifications NOTIFICATIONS: "notifications/me", diff --git a/frontend/src/styles/index.css b/frontend/src/styles/index.css index 4cc2edfc..2dd97fa8 100644 --- a/frontend/src/styles/index.css +++ b/frontend/src/styles/index.css @@ -27,6 +27,7 @@ body { --hot-fair-color-green-primary: #198155; --hot-fair-color-frosted-blue: #f7f9fb; --hot-fair-color-ink: #202325; + --hot-fair-color-yellow-secondary: #fffddb; /* Font sizes in rem */ --hot-fair-font-size-extra-large: 4.25rem; diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index aa65292d..d34b2a1b 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -271,6 +271,7 @@ export type TOfflinePrediction = { user: number; config: TModelPredictionsConfig; result_count: number; + published: boolean; result: null | { count: number; }; diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 8d30b66a..2ce7a959 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -15,7 +15,8 @@ export default { "hover-accent": "var( --hot-fair-color-hover-accent)", "green-secondary": "var(--hot-fair-color-green-secondary)", "green-primary": "var(--hot-fair-color-green-primary)", - "frosted-blue": "var(--hot-fair-color-frosted-blue)" + "frosted-blue": "var(--hot-fair-color-frosted-blue)", + "secondary-yellow": "var(--hot-fair-color-yellow-secondary)", }, fontFamily: { archivo: "var(--sl-font-sans)",
{message}