diff --git a/mobile/app.json b/mobile/app.json index 83f6a6057..fa7e79304 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -7,11 +7,6 @@ "orientation": "portrait", "icon": "./assets/icons/icon.png", "userInterfaceStyle": "light", - "splash": { - "image": "./assets/icons/splash.png", - "resizeMode": "contain", - "backgroundColor": "#FDD20C" - }, "assetBundlePatterns": [ "**/*" ], @@ -63,6 +58,18 @@ }, "plugins": [ "expo-router", + [ + "expo-splash-screen", + { + "image": "./assets/icons/splash-logo.png", + "resizeMode": "contain", + "backgroundColor": "#FDD20C", + "imageWidth": 190, + "ios": { + "imageWidth": 280 + } + } + ], [ "expo-font", { diff --git a/mobile/app/(observer)/(app)/(drawer)/(tabs)/(observation)/delete-submission/[submissionId].tsx b/mobile/app/(observer)/(app)/(drawer)/(tabs)/(observation)/delete-submission/[submissionId].tsx index 8f5de013d..544e6caf1 100644 --- a/mobile/app/(observer)/(app)/(drawer)/(tabs)/(observation)/delete-submission/[submissionId].tsx +++ b/mobile/app/(observer)/(app)/(drawer)/(tabs)/(observation)/delete-submission/[submissionId].tsx @@ -2,7 +2,6 @@ import { router, useLocalSearchParams } from "expo-router"; import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { RefreshControl } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; import { ScrollView, Separator, Spinner, YStack } from "tamagui"; import { getAnswerDisplay } from "../../../../../../../common/utils/answers"; import Button from "../../../../../../../components/Button"; @@ -13,6 +12,7 @@ import { Screen } from "../../../../../../../components/Screen"; import { Typography } from "../../../../../../../components/Typography"; import { useNetInfoContext } from "../../../../../../../contexts/net-info-banner/NetInfoContext"; import { useUserData } from "../../../../../../../contexts/user/UserContext.provider"; +import { useDateTimeFormat } from "../../../../../../../contexts/date-time-format/DateTimeFormatContext.provider"; import { shouldDisplayQuestion } from "../../../../../../../services/form.parser"; import { ApiFormAnswer, @@ -35,6 +35,7 @@ type SearchParamsType = { const DeleteFormSubmission = () => { const { t } = useTranslation(["delete_submission", "common"]); + const dateTimeFormat = useDateTimeFormat(); const { formId, submissionId, language, submissionNumber } = useLocalSearchParams(); const { isOnline } = useNetInfoContext(); @@ -46,7 +47,6 @@ const DeleteFormSubmission = () => { const { activeElectionRound, selectedPollingStation } = useUserData(); const [isRefreshing, setIsRefreshing] = useState(false); const [showWarningModal, setShowWarningModal] = useState(false); - const insets = useSafeAreaInsets(); const { mutate: deleteSubmission } = useDeleteFormSubmissionMutation({ electionRoundId: activeElectionRound?.id, @@ -254,7 +254,13 @@ const DeleteFormSubmission = () => { { onSuccess: () => { setShowWarningModal(false); - router.push(`/multi-submission-form-details/${formId}?language=${language}`); + // leave this preview: back to the form's submissions it was opened from, so back from there doesn't + // return to a deleted submission + if (router.canGoBack()) { + router.back(); + } else { + router.replace(`/multi-submission-form-details/${formId}?language=${language}`); + } }, }, ); @@ -309,48 +315,64 @@ const DeleteFormSubmission = () => { leftIcon={} onLeftPress={() => router.back()} /> - - - {formTitle} - {displayedQuestions.map((question) => ( - - - {question.code} - {question.text[language]} + {/* The answers scroll; the delete button stays pinned under them so it is always reachable. */} + } + > + {formTitle} + {displayedQuestions.map((question) => ( + + + {question.code} - {question.text[language]} + + {mappedAnswers && mappedAnswers[question.id] && ( + + {getAnswerDisplay( + mappedAnswers[question.id] as ApiFormAnswer, + true, + dateTimeFormat, + )} - {mappedAnswers && mappedAnswers[question.id] && ( - - {getAnswerDisplay(mappedAnswers[question.id] as ApiFormAnswer, true)} + )} + {attachments && attachments[question.id] ? ( + + + {t("attachments", { value: attachments[question.id].length })} + + + ) : ( + false + )} + + {notes && notes[question.id] ? ( + + + {t("notes", { value: notes[question.id].length })} - )} - {attachments && attachments[question.id] ? ( - - - {t("attachments", { value: attachments[question.id].length })} - - - ) : ( - false - )} - - {notes && notes[question.id] ? ( - - - {t("notes", { value: notes[question.id].length })} - - - ) : ( - false - )} - - - ))} - - - + + ) : ( + false + )} + - + ))} + + + + {showWarningModal && ( diff --git a/mobile/app/(observer)/(app)/(drawer)/(tabs)/(observation)/index.tsx b/mobile/app/(observer)/(app)/(drawer)/(tabs)/(observation)/index.tsx index 8e410ed06..826e368b4 100644 --- a/mobile/app/(observer)/(app)/(drawer)/(tabs)/(observation)/index.tsx +++ b/mobile/app/(observer)/(app)/(drawer)/(tabs)/(observation)/index.tsx @@ -19,6 +19,7 @@ import SingleSubmissionFormList from "../../../../../../components/SingleSubmiss import ObservationSkeleton from "../../../../../../components/SkeletonLoaders/ObservationSkeleton"; import { Typography } from "../../../../../../components/Typography"; import { useUserData } from "../../../../../../contexts/user/UserContext.provider"; +import { useSettledValue } from "../../../../../../hooks/useSettledValue"; import { usePollingStationInformation, usePollingStationInformationForm, @@ -36,6 +37,9 @@ const Index = () => { activeElectionRound?.id, selectedPollingStation?.pollingStationId, ); + // saving arrival / departure time or the PSI form updates this data while the screen comes back into view; + // showing the change only once it's settled avoids an Android crash (see useSettledValue) + const shownPsiData = useSettledValue(psiData); const { data: psiFormQuestions, isLoading: isLoadingPsiFormQuestions } = usePollingStationInformationForm(activeElectionRound?.id); @@ -192,7 +196,10 @@ const Index = () => { - + {t("forms.heading")} @@ -207,7 +214,10 @@ const Index = () => { - + {t("forms.heading")} diff --git a/mobile/app/(observer)/(app)/(drawer)/(tabs)/(quick-report)/incident-report-details/[incidentReportId].tsx b/mobile/app/(observer)/(app)/(drawer)/(tabs)/(quick-report)/incident-report-details/[incidentReportId].tsx new file mode 100644 index 000000000..000800fe0 --- /dev/null +++ b/mobile/app/(observer)/(app)/(drawer)/(tabs)/(quick-report)/incident-report-details/[incidentReportId].tsx @@ -0,0 +1,178 @@ +import { router, useLocalSearchParams } from "expo-router"; +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { RefreshControl } from "react-native"; +import { ScrollView, Separator, YStack } from "tamagui"; +import { getAnswerDisplay, mapAnswersForDisplay } from "../../../../../../../common/utils/answers"; +import { getFormLanguagePreference } from "../../../../../../../common/language.preferences"; +import Card from "../../../../../../../components/Card"; +import Header from "../../../../../../../components/Header"; +import { Icon } from "../../../../../../../components/Icon"; +import { MediaDialog } from "../../../../../../../components/MediaDialog"; +import { Screen } from "../../../../../../../components/Screen"; +import { Typography } from "../../../../../../../components/Typography"; +import { useDateTimeFormat } from "../../../../../../../contexts/date-time-format/DateTimeFormatContext.provider"; +import { useUserData } from "../../../../../../../contexts/user/UserContext.provider"; +import { + getFormDisplayName, + getFormLanguage, + getIncidentReportLocationLabel, +} from "../../../../../../../helpers/incidentReport"; +import { AttachmentMimeType } from "../../../../../../../services/api/get-attachments.api"; +import { IncidentReportAttachmentAPIResponse } from "../../../../../../../services/api/incident-report/incident-report.api"; +import { useFormById } from "../../../../../../../services/queries/forms.query"; +import { useIncidentReportById } from "../../../../../../../services/queries/incident-reports.query"; + +const IncidentReportDetails = () => { + const { incidentReportId } = useLocalSearchParams<{ incidentReportId: string }>(); + const { t } = useTranslation([ + "incident_report", + "report_details", + "polling_station_form_wizard", + "common", + ]); + const dateTimeFormat = useDateTimeFormat(); + const { activeElectionRound, visits } = useUserData(); + + const [previewAttachment, setPreviewAttachment] = + useState(null); + + const { + data: incidentReport, + isLoading, + error, + refetch, + isRefetching, + } = useIncidentReportById(activeElectionRound?.id, incidentReportId ?? ""); + const { data: form } = useFormById(activeElectionRound?.id, incidentReport?.formId ?? ""); + + // the language the observer picked for this form, if any + const [preferredLanguage, setPreferredLanguage] = useState(null); + useEffect(() => { + if (form?.id) { + getFormLanguagePreference({ formId: form.id }).then(setPreferredLanguage); + } + }, [form?.id]); + + const language = useMemo( + () => + preferredLanguage && form?.languages.includes(preferredLanguage) + ? preferredLanguage + : getFormLanguage(form), + [form, preferredLanguage], + ); + + const answersByQuestionId = useMemo( + () => + mapAnswersForDisplay( + Object.fromEntries((incidentReport?.answers || []).map((a) => [a.questionId, a])), + form?.questions, + language, + ), + [incidentReport?.answers, form?.questions, language], + ); + + const answeredQuestions = useMemo( + () => + form?.questions.filter( + (question) => + answersByQuestionId[question.id] || + incidentReport?.notes.some((note) => note.questionId === question.id), + ) || [], + [form?.questions, answersByQuestionId, incidentReport?.notes], + ); + + const attachments = incidentReport?.attachments || []; + + if (isLoading) { + return {t("loading", { ns: "common" })}; + } + + return ( + +
} + onLeftPress={() => router.back()} + /> + + + } + > + {error || !incidentReport ? ( + {t("error", { ns: "report_details" })} + ) : ( + <> + + {t("location.label")} + + {getIncidentReportLocationLabel(incidentReport, visits)} + + + + + {answeredQuestions.map((question) => ( + + {question.text[language]} + {answersByQuestionId[question.id] ? ( + + {getAnswerDisplay(answersByQuestionId[question.id], true, dateTimeFormat)} + + ) : ( + false + )} + {incidentReport.notes + .filter((note) => note.questionId === question.id) + .map((note, index) => ( + + {`${t("notes.heading", { ns: "polling_station_form_wizard" })}: ${note.text}`} + + ))} + + + ))} + + + {attachments.length === 0 ? ( + {t("no_files", { ns: "report_details" })} + ) : ( + + + {t("uploaded_media", { ns: "report_details" })} + + {attachments.map((attachment, index) => ( + setPreviewAttachment(attachment)} + > + + {attachment.fileName} + + + ))} + + )} + + )} + + {previewAttachment && previewAttachment.mimeType === AttachmentMimeType.IMG && ( + setPreviewAttachment(null)} + /> + )} + + + + ); +}; + +export default IncidentReportDetails; diff --git a/mobile/app/(observer)/(app)/(drawer)/(tabs)/(quick-report)/incident-report-details/_layout.tsx b/mobile/app/(observer)/(app)/(drawer)/(tabs)/(quick-report)/incident-report-details/_layout.tsx new file mode 100644 index 000000000..54ff1996a --- /dev/null +++ b/mobile/app/(observer)/(app)/(drawer)/(tabs)/(quick-report)/incident-report-details/_layout.tsx @@ -0,0 +1,7 @@ +import { Slot } from "expo-router"; + +const IncidentReportDetailsLayout = () => { + return ; +}; + +export default IncidentReportDetailsLayout; diff --git a/mobile/app/(observer)/(app)/(drawer)/(tabs)/(quick-report)/index.tsx b/mobile/app/(observer)/(app)/(drawer)/(tabs)/(quick-report)/index.tsx index 4aa1b8e3a..960ccd1da 100644 --- a/mobile/app/(observer)/(app)/(drawer)/(tabs)/(quick-report)/index.tsx +++ b/mobile/app/(observer)/(app)/(drawer)/(tabs)/(quick-report)/index.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useMemo, useState } from "react"; import { Typography } from "../../../../../../components/Typography"; import { Spinner, XStack, YStack } from "tamagui"; import { Icon } from "../../../../../../components/Icon"; @@ -8,64 +8,120 @@ import Header from "../../../../../../components/Header"; import { DrawerActions } from "@react-navigation/native"; import Button from "../../../../../../components/Button"; import { useQuickReports } from "../../../../../../services/queries/quick-reports.query"; +import { + useIncidentReportingForms, + useIncidentReports, +} from "../../../../../../services/queries/incident-reports.query"; import { useUserData } from "../../../../../../contexts/user/UserContext.provider"; import { ListView } from "../../../../../../components/ListView"; import ReportCard from "../../../../../../components/ReportCard"; +import IncidentReportCard from "../../../../../../components/IncidentReportCard"; +import OptionsSheet from "../../../../../../components/OptionsSheet"; import { RefreshControl, ViewStyle } from "react-native"; import { QuickReportsAPIResponse } from "../../../../../../services/api/quick-report/get-quick-reports.api"; +import { IncidentReportAPIResponse } from "../../../../../../services/api/incident-report/incident-report.api"; +import { FormAPIModel } from "../../../../../../services/definitions.api"; import { useTranslation } from "react-i18next"; -import { ElectionRoundVM } from "../../../../../../common/models/election-round.model"; import InfoModal from "../../../../../../components/InfoModal"; +import SelectFormLanguageDialog from "../../../../../../components/SelectFormLanguageDialog"; +import { + getFormLanguagePreference, + setFormLanguagePreference, +} from "../../../../../../common/language.preferences"; import { useNetInfoContext } from "../../../../../../contexts/net-info-banner/NetInfoContext"; +import { + getFormDisplayName, + getFormLanguage, + getIncidentReportLocationLabel, +} from "../../../../../../helpers/incidentReport"; + +type ReportListItem = + | { kind: "quick-report"; timestamp?: string; report: QuickReportsAPIResponse } + | { kind: "incident-report"; timestamp?: string; report: IncidentReportAPIResponse }; + +// newest first; reports not synched yet (no timestamp) stay on top +const byTimestampDesc = (a: ReportListItem, b: ReportListItem) => { + const aTime = a.timestamp ? new Date(a.timestamp).getTime() : Number.MAX_SAFE_INTEGER; + const bTime = b.timestamp ? new Date(b.timestamp).getTime() : Number.MAX_SAFE_INTEGER; + return bTime - aTime; +}; const QuickReport = () => { - const { t } = useTranslation("quick_report"); + const { t } = useTranslation(["quick_report", "incident_report"]); const navigation = useNavigation(); const [isOpenInfoModal, setIsOpenInfoModal] = useState(false); + const [isReportTypeSheetOpen, setIsReportTypeSheetOpen] = useState(false); + const [formToSelectLanguage, setFormToSelectLanguage] = useState(null); const { activeElectionRound } = useUserData(); const { data: quickReports, isLoading, error } = useQuickReports(activeElectionRound?.id); + const { data: incidentReportingForms } = useIncidentReportingForms(activeElectionRound?.id); + const { data: incidentReports } = useIncidentReports(activeElectionRound?.id); + + // with no incident reporting forms there is nothing to choose from: open the quick report directly + const handleAddReport = () => { + if (incidentReportingForms?.length) { + setIsReportTypeSheetOpen(true); + } else { + router.push("/report-issue"); + } + }; + + const openQuickReport = () => { + setIsReportTypeSheetOpen(false); + router.push("/report-issue"); + }; - const handleOpenInfoModal = () => { - setIsOpenInfoModal(true); + const openIncidentReport = (form: FormAPIModel, language: string) => { + setFormLanguagePreference({ formId: form.id, language }); + setFormToSelectLanguage(null); + router.push(`/incident-report?formId=${form.id}&language=${language}`); }; - const handleCloseInfoModal = () => { - setIsOpenInfoModal(false); + // same as the observation forms: the saved language, the only language, or ask + const selectIncidentForm = async (form: FormAPIModel) => { + setIsReportTypeSheetOpen(false); + const preferredLanguage = await getFormLanguagePreference({ formId: form.id }); + + if (preferredLanguage && form.languages.includes(preferredLanguage)) { + openIncidentReport(form, preferredLanguage); + } else if (form.languages.length === 1) { + openIncidentReport(form, form.languages[0]); + } else { + setFormToSelectLanguage(form); + } }; return ( <>
} onLeftPress={() => navigation.dispatch(DrawerActions.openDrawer)} rightIcon={} - onRightPress={handleOpenInfoModal} + onRightPress={() => setIsOpenInfoModal(true)} /> {quickReports ? ( ) : ( false )} - {quickReports?.length ? ( + {quickReports?.length || incidentReports?.length ? ( - @@ -73,10 +129,48 @@ const QuickReport = () => { false )} + {isReportTypeSheetOpen && ( + + + + {t("report_type_sheet.title", { ns: "incident_report" })} + + {/* quick report is always the first option */} + + {t("report_type_sheet.quick_report", { ns: "incident_report" })} + + {incidentReportingForms?.map((form: FormAPIModel) => ( + selectIncidentForm(form)} + > + {getFormDisplayName(form)} + + ))} + + + )} + + {formToSelectLanguage && ( + openIncidentReport(formToSelectLanguage, language)} + onCancel={() => setFormToSelectLanguage(null)} + /> + )} + {isOpenInfoModal && ( setIsOpenInfoModal(false)} /> )} @@ -85,27 +179,69 @@ const QuickReport = () => { interface QuickReportContentProps { quickReports: QuickReportsAPIResponse[]; + incidentReportingForms: FormAPIModel[] | undefined; isLoading: boolean; error: Error | null; - activeElectionRound: ElectionRoundVM | undefined; + electionRoundId: string | undefined; + onAddReport: () => void; } const QuickReportContent = ({ quickReports, + incidentReportingForms, isLoading, error, - activeElectionRound, + electionRoundId, + onAddReport, }: QuickReportContentProps) => { const { t } = useTranslation(["quick_report", "common"]); const { isOnline } = useNetInfoContext(); + const { visits } = useUserData(); const [isRefreshing, setIsRefreshing] = useState(false); - const { refetch } = useQuickReports(activeElectionRound?.id); + const { refetch: refetchQuickReports } = useQuickReports(electionRoundId); + // a failing incident reports request should not hide the quick reports + const { data: incidentReports, refetch: refetchIncidentReports } = + useIncidentReports(electionRoundId); + + // a sent report is locked and only viewed, a draft is continued in the language it was started in + const openListedIncidentReport = async (report: IncidentReportAPIResponse) => { + if (report.isCompleted) { + router.push(`/incident-report-details/${report.id}`); + return; + } + const form = incidentReportingForms?.find((f) => f.id === report.formId); + const preferredLanguage = await getFormLanguagePreference({ formId: report.formId }); + const language = + preferredLanguage && form?.languages.includes(preferredLanguage) + ? preferredLanguage + : getFormLanguage(form); + router.push( + `/incident-report?formId=${report.formId}&incidentReportId=${report.id}&language=${language}`, + ); + }; + + const reports: ReportListItem[] = useMemo( + () => + [ + ...quickReports.map((report): ReportListItem => ({ + kind: "quick-report", + timestamp: report.timestamp, + report, + })), + ...(incidentReports || []).map((report): ReportListItem => ({ + kind: "incident-report", + timestamp: report.isNotSynched ? undefined : report.timestamp, + report, + })), + ].sort(byTimestampDesc), + [quickReports, incidentReports], + ); const handleRefetch = () => { setIsRefreshing(true); - refetch().then(() => { + Promise.all([refetchQuickReports(), refetchIncidentReports()]).then(() => { setIsRefreshing(false); }); }; @@ -124,8 +260,8 @@ const QuickReportContent = ({ return ( - - data={quickReports} + + data={reports} showsVerticalScrollIndicator={false} contentContainerStyle={{ padding: 16 }} ListEmptyComponent={ @@ -141,7 +277,7 @@ const QuickReportContent = ({ width="100%" height="100%" textStyle={{ textAlign: "center" }} - onPress={() => router.push("/report-issue")} + onPress={onAddReport} backgroundColor="white" > {t("list.add")} @@ -151,16 +287,33 @@ const QuickReportContent = ({ } bounces={isOnline} - renderItem={({ item, index }) => ( - router.push(`/report-details/${item.id}?reportTitle=${item.title}`)} - /> - )} + renderItem={({ item }) => + item.kind === "quick-report" ? ( + + router.push(`/report-details/${item.report.id}?reportTitle=${item.report.title}`) + } + /> + ) : ( + form.id === item.report.formId), + )} + location={getIncidentReportLocationLabel(item.report, visits)} + numberOfAnswers={item.report.answers.length} + numberOfAttachments={item.report.attachments.length} + isCompleted={item.report.isCompleted} + timestamp={item.report.timestamp} + onPress={() => openListedIncidentReport(item.report)} + /> + ) + } refreshControl={} /> diff --git a/mobile/app/(observer)/(app)/(drawer)/(tabs)/more.tsx b/mobile/app/(observer)/(app)/(drawer)/(tabs)/more.tsx index 19ffce2f5..98a8f09c0 100644 --- a/mobile/app/(observer)/(app)/(drawer)/(tabs)/more.tsx +++ b/mobile/app/(observer)/(app)/(drawer)/(tabs)/more.tsx @@ -5,6 +5,8 @@ import { Screen } from "../../../../../components/Screen"; import { Typography } from "../../../../../components/Typography"; import { Icon } from "../../../../../components/Icon"; import { useTranslation } from "react-i18next"; +import { getDateFormatLabel } from "../../../../../common/utils/time"; +import { useDateTimeFormat } from "../../../../../contexts/date-time-format/DateTimeFormatContext.provider"; import { openExternalUrl } from "../../../../../common/utils/utils"; import { router, useNavigation } from "expo-router"; import { useAuth } from "../../../../../hooks/useAuth"; @@ -45,7 +47,8 @@ export const MoreMenuItem = ({ label, helper, icon, chevronRight, onClick }: Men ); const More = () => { - const { t, i18n } = useTranslation(["more", "languages", "common"]); + const { t, i18n } = useTranslation(["more", "languages", "common", "dateTimeFormatSettings"]); + const { is24Hour, dateFormat } = useDateTimeFormat(); const { isOnline } = useNetInfoContext(); const { signOut } = useAuth(); @@ -102,6 +105,13 @@ const More = () => { onClick={setIsLanguageSelectSheetOpen.bind(null, true)} helper={t(i18n.language, { ns: "languages" })} > + router.push("/date-time-settings")} + helper={`${is24Hour ? t("time_format.24h", { ns: "dateTimeFormatSettings" }) : t("time_format.12h", { ns: "dateTimeFormatSettings" })} · ${getDateFormatLabel(dateFormat)}`} + > { ( - - {`${round.status} - ${round.title}`} - + + + {round.title} + + {round.englishTitle && round.englishTitle.trim() !== round.title.trim() ? ( + + {round.englishTitle} + + ) : ( + false + )} + )} focused={activeElectionRound?.id === round.id} activeTintColor={theme.purple5?.val} diff --git a/mobile/app/(observer)/(app)/_layout.tsx b/mobile/app/(observer)/(app)/_layout.tsx index e87b9f5ed..b9f403ddf 100644 --- a/mobile/app/(observer)/(app)/_layout.tsx +++ b/mobile/app/(observer)/(app)/_layout.tsx @@ -31,8 +31,13 @@ const AppLayout = () => { options={{ headerShown: false, gestureEnabled: false }} /> + + diff --git a/mobile/app/(observer)/(app)/date-time-settings.tsx b/mobile/app/(observer)/(app)/date-time-settings.tsx new file mode 100644 index 000000000..fabd789a8 --- /dev/null +++ b/mobile/app/(observer)/(app)/date-time-settings.tsx @@ -0,0 +1,3 @@ +import DateTimeSettingsScreen from "../../../components/DateTimeSettingsScreen"; + +export default DateTimeSettingsScreen; diff --git a/mobile/app/(observer)/(app)/form-questionnaire/[questionId].tsx b/mobile/app/(observer)/(app)/form-questionnaire/[questionId].tsx index c1db4bbfd..d5f6f437a 100644 --- a/mobile/app/(observer)/(app)/form-questionnaire/[questionId].tsx +++ b/mobile/app/(observer)/(app)/form-questionnaire/[questionId].tsx @@ -1,9 +1,7 @@ import * as Sentry from "@sentry/react-native"; import { useQueryClient } from "@tanstack/react-query"; -import { Buffer } from "buffer"; import * as Crypto from "expo-crypto"; import * as DocumentPicker from "expo-document-picker"; -import * as FileSystem from "expo-file-system/legacy"; import { router, useLocalSearchParams } from "expo-router"; import { useEffect, useMemo, useRef, useState } from "react"; import { useForm } from "react-hook-form"; @@ -37,6 +35,7 @@ import { useCamera } from "../../../../hooks/useCamera"; import { AddAttachmentStartAPIPayload } from "../../../../services/api/add-attachment.api"; import { mapFormSubmissionDataToAPIFormSubmissionAnswer, + withAnswerTimestamps, removeAnswersForHiddenQuestions, setFormDefaultValues, shouldDisplayQuestion, @@ -160,10 +159,14 @@ const FormQuestionnaire = () => { if (activeElectionRound?.id && selectedPollingStation?.pollingStationId && activeQuestion) { // map the answer values - const updatedAnswer = mapFormSubmissionDataToAPIFormSubmissionAnswer( - questionId, - activeQuestion?.question.$questionType, - formValues[questionId], + // stamped with when it was first given and last changed (kept as is when unchanged) + const updatedAnswer = withAnswerTimestamps( + mapFormSubmissionDataToAPIFormSubmissionAnswer( + questionId, + activeQuestion?.question.$questionType, + formValues[questionId], + ), + answers?.[questionId], ); // Remove answers only for dependent questions that are no longer displayed @@ -511,13 +514,12 @@ const FormQuestionnaire = () => { throw new Error("Upload cancelled"); } - const chunk = await FileSystem.readAsStringAsync(filePath, { - length: MULTIPART_FILE_UPLOAD_SIZE, - position: index * MULTIPART_FILE_UPLOAD_SIZE, - encoding: FileSystem.EncodingType.Base64, + const data = await uploadS3Chunk({ + url, + filePath, + partIndex: index, + partSize: MULTIPART_FILE_UPLOAD_SIZE, }); - const buffer = Buffer.from(chunk, "base64"); - const data = await uploadS3Chunk({ url, chunk: buffer }); setUploadProgress( `${t("attachments.upload.progress")} ${Math.ceil(((index + 1) / totalParts) * 100)}%`, ); diff --git a/mobile/app/(observer)/(app)/form-submissions/[submissionId].tsx b/mobile/app/(observer)/(app)/form-submissions/[submissionId].tsx index 0cd6a20cd..631ef571c 100644 --- a/mobile/app/(observer)/(app)/form-submissions/[submissionId].tsx +++ b/mobile/app/(observer)/(app)/form-submissions/[submissionId].tsx @@ -1,9 +1,7 @@ import * as Sentry from "@sentry/react-native"; import { useQueryClient } from "@tanstack/react-query"; -import { Buffer } from "buffer"; import * as Crypto from "expo-crypto"; import * as DocumentPicker from "expo-document-picker"; -import * as FileSystem from "expo-file-system/legacy"; import { router, useLocalSearchParams } from "expo-router"; import { useEffect, useMemo, useRef, useState } from "react"; import { useForm } from "react-hook-form"; @@ -37,6 +35,7 @@ import { useCamera } from "../../../../hooks/useCamera"; import { AddAttachmentStartAPIPayload } from "../../../../services/api/add-attachment.api"; import { mapFormSubmissionDataToAPIFormSubmissionAnswer, + withAnswerTimestamps, setFormDefaultValues, shouldDisplayQuestion, } from "../../../../services/form.parser"; @@ -159,10 +158,14 @@ const FormSubmissions = () => { if (activeElectionRound?.id && selectedPollingStation?.pollingStationId && activeQuestion) { // map the answer values - const updatedAnswer = mapFormSubmissionDataToAPIFormSubmissionAnswer( - questionId, - activeQuestion?.question.$questionType, - formValues[questionId], + // stamped with when it was first given and last changed (kept as is when unchanged) + const updatedAnswer = withAnswerTimestamps( + mapFormSubmissionDataToAPIFormSubmissionAnswer( + questionId, + activeQuestion?.question.$questionType, + formValues[questionId], + ), + answers?.[questionId], ); const updatedAnswers = { @@ -517,13 +520,12 @@ const FormSubmissions = () => { throw new Error("Upload cancelled"); } - const chunk = await FileSystem.readAsStringAsync(filePath, { - length: MULTIPART_FILE_UPLOAD_SIZE, - position: index * MULTIPART_FILE_UPLOAD_SIZE, - encoding: FileSystem.EncodingType.Base64, + const data = await uploadS3Chunk({ + url, + filePath, + partIndex: index, + partSize: MULTIPART_FILE_UPLOAD_SIZE, }); - const buffer = Buffer.from(chunk, "base64"); - const data = await uploadS3Chunk({ url, chunk: buffer }); setUploadProgress( `${t("attachments.upload.progress")} ${Math.ceil(((index + 1) / totalParts) * 100)}%`, ); diff --git a/mobile/app/(observer)/(app)/incident-report.tsx b/mobile/app/(observer)/(app)/incident-report.tsx new file mode 100644 index 000000000..bccc676dd --- /dev/null +++ b/mobile/app/(observer)/(app)/incident-report.tsx @@ -0,0 +1,1171 @@ +import * as Sentry from "@sentry/react-native"; +import { onlineManager, useQueryClient } from "@tanstack/react-query"; +import * as Crypto from "expo-crypto"; +import * as DocumentPicker from "expo-document-picker"; +import { router, useLocalSearchParams } from "expo-router"; +import { TFunction } from "i18next"; +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { BackHandler, Keyboard, ViewStyle } from "react-native"; +import Toast from "react-native-toast-message"; +import { Spinner, XStack, YStack } from "tamagui"; +import { PollingStationVisitVM } from "../../../common/models/polling-station.model"; +import { + MULTIPART_FILE_UPLOAD_SIZE, + MUTATION_SCOPE_DO_NOT_HYDRATE, +} from "../../../common/constants"; +import AddAttachment from "../../../components/AddAttachment"; +import Card from "../../../components/Card"; +import FormElement from "../../../components/FormInputs/FormElement"; +import FormInput from "../../../components/FormInputs/FormInput"; +import Header from "../../../components/Header"; +import { Icon } from "../../../components/Icon"; +import LinearProgress from "../../../components/LinearProgress"; +import { MediaDialog } from "../../../components/MediaDialog"; +import MediaLoading from "../../../components/MediaLoading"; +import NoteForm from "../../../components/NoteForm"; +import OptionsSheet from "../../../components/OptionsSheet"; +import ChangeLanguageDialog from "../../../components/ChangeLanguageDialog"; +import { setFormLanguagePreference } from "../../../common/language.preferences"; +import QuestionForm from "../../../components/QuestionForm"; +import { Screen } from "../../../components/Screen"; +import { ScreenScrollView } from "../../../components/ScreenScrollView"; +import Select from "../../../components/Select"; +import { Typography } from "../../../components/Typography"; +import WarningDialog from "../../../components/WarningDialog"; +import WizzardControls from "../../../components/WizzardControls"; +import { useNetInfoContext } from "../../../contexts/net-info-banner/NetInfoContext"; +import { useUserData } from "../../../contexts/user/UserContext.provider"; +import { getFormDisplayName, getFormLanguage } from "../../../helpers/incidentReport"; +import { FileMetadata, useCamera } from "../../../hooks/useCamera"; +import { AttachmentMimeType } from "../../../services/api/get-attachments.api"; +import { IncidentReportLocationType } from "../../../services/api/incident-report/incident-report.api"; +import { + mapFormSubmissionDataToAPIFormSubmissionAnswer, + removeAnswersForHiddenQuestions, + setFormDefaultValues, + shouldDisplayQuestion, + withAnswerTimestamps, +} from "../../../services/form.parser"; +import { ApiFormAnswer } from "../../../services/interfaces/answer.type"; +import { ApiFormQuestion } from "../../../services/interfaces/question.type"; +import { + removeMutationByScopeId, + useUploadS3ChunkMutation, +} from "../../../services/mutations/attachments/add-attachment.mutation"; +import { + useUploadAttachmentIncidentReportAbortMutation, + useUploadAttachmentIncidentReportCompleteMutation, + useUploadAttachmentIncidentReportMutation, +} from "../../../services/mutations/incident-report/add-attachment-incident-report.mutation"; +import { + useAddIncidentReport, + useAddIncidentReportNote, + useDeleteIncidentReportAttachment, + useDeleteIncidentReportNote, +} from "../../../services/mutations/incident-report/add-incident-report.mutation"; +import { useFormById } from "../../../services/queries/forms.query"; +import { + IncidentReportKeys, + useIncidentReportAttachments, + useIncidentReportById, +} from "../../../services/queries/incident-reports.query"; + +// without an id (from a server that does not send it) a note can only be shown, not edited or deleted +type ReportNote = { id?: string; text: string }; +// without an id (when it could not be loaded with its id) a file can only be shown, not deleted +type ReportAttachment = { id?: string; fileName: string; mimeType: string; uri: string }; + +// the active question's value, keyed by question id (the shape setFormDefaultValues gives) +type QuestionFormValues = NonNullable>; + +type LocationFormType = { + // a visited polling station id, or one of the OtherPollingStation / NotRelatedToAPollingStation location types + location: string; + locationDescription: string; +}; + +const mapVisitsToLocationOptions = (visits: PollingStationVisitVM[] = [], t: TFunction) => [ + ...visits.map((visit) => ({ + id: visit.pollingStationId, + value: visit.pollingStationId, + label: `${visit.number} - ${visit.address}`, + })), + { + id: "other", + value: IncidentReportLocationType.OtherPollingStation, + label: t("form.polling_station_id.options.other", { ns: "report_new_issue" }), + }, + { + id: "not_related_to_polling_station", + value: IncidentReportLocationType.NotRelatedToAPollingStation, + label: t("form.polling_station_id.options.not_related", { ns: "report_new_issue" }), + }, +]; + +const isLocationType = (location: string) => + location === IncidentReportLocationType.OtherPollingStation || + location === IncidentReportLocationType.NotRelatedToAPollingStation; + +const groupByQuestion = ( + items: T[], + map: (item: T) => R, +): Record => + items.reduce( + (acc, item) => { + acc[item.questionId] = [...(acc[item.questionId] || []), map(item)]; + return acc; + }, + {} as Record, + ); + +/* + An incident report is saved while it is filled in, so the organization has whatever the observer + managed to send even if they can not finish it: + - the draft (isCompleted: false) is created once the location is chosen, and saved again with all + the answers on every "Next step" / "Back" (queued while offline, sent in order later) + - notes are sent when they are saved, edited or deleted (queued while offline, after the report) + - media files are uploaded as soon as they are picked (that needs a connection) + - "Send report" (any question, one tap) saves it with isCompleted: true, which locks + it (the server refuses further changes) + A draft can be continued later from the report list (incidentReportId param). +*/ +const IncidentReport = () => { + const params = useLocalSearchParams<{ + formId: string; + language?: string; + // set when continuing a draft + incidentReportId?: string; + }>(); + const { formId, language: languageParam } = params; + const isResuming = !!params.incidentReportId; + + const { t } = useTranslation([ + "incident_report", + "report_new_issue", + "report_details", + "polling_station_form_wizard", + "languages", + ]); + const queryClient = useQueryClient(); + const cancelRef = useRef(false); + + const { visits, activeElectionRound } = useUserData(); + const { isOnline } = useNetInfoContext(); + const { uploadCameraOrMedia } = useCamera(); + + const [incidentReportId] = useState(params.incidentReportId ?? Crypto.randomUUID()); + + const { data: currentForm, isLoading: isLoadingForm } = useFormById( + activeElectionRound?.id, + formId, + ); + const { data: existingReport, isLoading: isLoadingReport } = useIncidentReportById( + activeElectionRound?.id, + incidentReportId, + ); + const { data: existingAttachments, isFetchedAfterMount: areExistingAttachmentsFresh } = + useIncidentReportAttachments( + activeElectionRound?.id, + incidentReportId, + formId, + isResuming && isOnline, + ); + + // once the draft is saved (or queued while offline) the location is fixed: the server keeps the first one + const [isSaved, setIsSaved] = useState(isResuming); + // whether the report is known to exist on the server, which uploading media needs + const isOnServerRef = useRef(isResuming); + // once sent the report is locked: nothing is saved or sent again from this screen (e.g. a second tap) + const isSentRef = useRef(false); + const [isInitialized, setIsInitialized] = useState(!isResuming); + + const [step, setStep] = useState<"location" | "questions">(isResuming ? "questions" : "location"); + const [questionId, setQuestionId] = useState(); + const [answers, setAnswers] = useState>({}); + const [notes, setNotes] = useState>({}); + const [attachments, setAttachments] = useState>({}); + + // when set, the options sheet shows the note form (a new note, or editing the one with noteId) + const [noteEditor, setNoteEditor] = useState<{ noteId?: string; text?: string } | null>(null); + const [selectedNoteToDelete, setSelectedNoteToDelete] = useState(null); + const [selectedAttachmentToDelete, setSelectedAttachmentToDelete] = useState(null); + const [previewAttachment, setPreviewAttachment] = useState(null); + + const [showWarningDialog, setShowWarningDialog] = useState(false); + const [isOptionsSheetOpen, setIsOptionsSheetOpen] = useState(false); + const [isPreparingFile, setIsPreparingFile] = useState(false); + const [isUploading, setIsUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState(""); + + const { mutate: saveReport, mutateAsync: saveReportAsync } = + useAddIncidentReport(incidentReportId); + const { mutate: saveNoteOnServer } = useAddIncidentReportNote(incidentReportId); + const { mutate: deleteNoteOnServer } = useDeleteIncidentReportNote(incidentReportId); + const { mutateAsync: deleteAttachmentOnServer } = useDeleteIncidentReportAttachment(); + const { mutateAsync: initAttachment } = useUploadAttachmentIncidentReportMutation(); + const { mutateAsync: completeAttachment } = useUploadAttachmentIncidentReportCompleteMutation(); + const { mutateAsync: abortAttachment } = useUploadAttachmentIncidentReportAbortMutation(); + const { mutateAsync: uploadS3Chunk } = useUploadS3ChunkMutation(); + + const locationOptions = useMemo(() => mapVisitsToLocationOptions(visits, t), [visits, t]); + + const { + control: locationControl, + handleSubmit: handleLocationSubmit, + watch: watchLocation, + getValues: getLocationValues, + reset: resetLocationForm, + formState: { errors: locationErrors, isDirty: isLocationDirty }, + } = useForm({ + defaultValues: { location: "", locationDescription: "" }, + }); + const locationWatch = watchLocation("location"); + + // the language picked when choosing the form (or switched to here), if the form still has it + const [selectedLanguage, setSelectedLanguage] = useState(languageParam); + const [isChangeLanguageOpen, setIsChangeLanguageOpen] = useState(false); + const [isFormMenuOpen, setIsFormMenuOpen] = useState(false); + const language = useMemo( + () => + selectedLanguage && currentForm?.languages.includes(selectedLanguage) + ? selectedLanguage + : getFormLanguage(currentForm), + [currentForm, selectedLanguage], + ); + + // only changes how the questions are shown: answers are kept by question / option id + const onChangeLanguage = (formId: string, newLanguage: string) => { + setFormLanguagePreference({ formId, language: newLanguage }); + setSelectedLanguage(newLanguage); + setIsChangeLanguageOpen(false); + }; + const formTitle = useMemo( + () => (currentForm ? `${getFormDisplayName(currentForm, language)} (${language})` : ""), + [currentForm, language], + ); + + const displayedQuestions: ApiFormQuestion[] = useMemo( + () => currentForm?.questions?.filter((q) => shouldDisplayQuestion(q, answers)) || [], + [currentForm, answers], + ); + + const activeQuestion = useMemo(() => { + const question = currentForm?.questions.find((q) => q.id === questionId); + if (!currentForm || !question) return undefined; + return { + indexInAllQuestions: currentForm.questions.findIndex((q) => q.id === questionId), + indexInDisplayedQuestions: displayedQuestions.findIndex((q) => q.id === questionId), + question, + }; + }, [currentForm, displayedQuestions, questionId]); + + const { + control: questionControl, + handleSubmit: handleQuestionSubmit, + reset: resetQuestionForm, + } = useForm(); + + // one screen walks through all the questions, so load the saved answer whenever the question changes + useEffect(() => { + if (questionId) { + resetQuestionForm(setFormDefaultValues(questionId, answers[questionId])); + } + }, [questionId]); + + const findNextQuestion = ( + index: number, + updatedAnswers: Record, + ): ApiFormQuestion | null => { + if (!currentForm || index + 1 >= currentForm.questions.length) return null; + const nextQuestion = currentForm.questions[index + 1]; + if (shouldDisplayQuestion(nextQuestion, updatedAnswers)) return nextQuestion; + return findNextQuestion(index + 1, updatedAnswers); + }; + + const findPreviousQuestion = ( + index: number, + updatedAnswers: Record, + ): ApiFormQuestion | null => { + if (!currentForm || index - 1 < 0) return null; + const prevQuestion = currentForm.questions[index - 1]; + if (shouldDisplayQuestion(prevQuestion, updatedAnswers)) return prevQuestion; + return findPreviousQuestion(index - 1, updatedAnswers); + }; + + // continuing a draft: fill in what was already sent + useEffect(() => { + if (isInitialized || !currentForm || !existingReport) return; + + if (existingReport.isCompleted) { + // a sent report is locked, it can only be viewed + router.replace(`/incident-report-details/${incidentReportId}`); + return; + } + + resetLocationForm({ + location: + existingReport.locationType === IncidentReportLocationType.PollingStation + ? existingReport.pollingStationId || "" + : existingReport.locationType, + locationDescription: existingReport.locationDescription || "", + }); + const existingAnswers = Object.fromEntries( + existingReport.answers.map((answer) => [answer.questionId, answer]), + ); + setAnswers(existingAnswers); + setNotes(groupByQuestion(existingReport.notes, (note) => ({ id: note.id, text: note.text }))); + setAttachments( + groupByQuestion(existingReport.attachments, (attachment) => ({ + fileName: attachment.fileName, + mimeType: attachment.mimeType, + uri: attachment.presignedUrl, + })), + ); + setQuestionId(findNextQuestion(-1, existingAnswers)?.id); + setIsInitialized(true); + }, [currentForm, existingReport, isInitialized]); + + // the attachments with their ids (so they can be deleted) replace the ones from the report list + const appliedExistingAttachmentsRef = useRef(false); + useEffect(() => { + // a cached list can be older than the report (e.g. a file uploaded since), so only a fresh one is used + if ( + !isInitialized || + !existingAttachments || + !areExistingAttachmentsFresh || + appliedExistingAttachmentsRef.current + ) { + return; + } + appliedExistingAttachmentsRef.current = true; + setAttachments((current) => { + const fromServer = groupByQuestion(existingAttachments, (attachment): ReportAttachment => ({ + id: attachment.id, + fileName: attachment.fileName, + mimeType: attachment.mimeType, + uri: attachment.presignedUrl, + })); + // keep the ones uploaded in the meantime + Object.entries(current).forEach(([id, questionAttachments]) => { + const known = new Set((fromServer[id] || []).map((a) => a.id)); + const added = questionAttachments.filter((a) => a.id && !known.has(a.id)); + if (added.length) fromServer[id] = [...(fromServer[id] || []), ...added]; + }); + return fromServer; + }); + }, [isInitialized, existingAttachments, areExistingAttachmentsFresh]); + + const handleGoBack = () => { + // a saved draft can be continued from the report list, only an unsaved location is lost + if (!isSaved && isLocationDirty) { + Keyboard.dismiss(); + setShowWarningDialog(true); + } else { + router.back(); + } + }; + + useEffect(() => { + const backHandler = BackHandler.addEventListener("hardwareBackPress", () => { + handleGoBack(); + return true; + }); + return () => backHandler.remove(); + }, [isSaved, isLocationDirty]); + + const getLocation = () => { + const { location, locationDescription } = getLocationValues(); + return isLocationType(location) + ? { + locationType: location as IncidentReportLocationType, + locationDescription, + } + : { locationType: IncidentReportLocationType.PollingStation, pollingStationId: location }; + }; + + const buildReportPayload = ( + reportAnswers: Record, + isCompleted: boolean, + ) => { + const now = new Date().toISOString(); + return { + id: incidentReportId, + electionRoundId: activeElectionRound!.id, + formId: currentForm!.id, + ...getLocation(), + answers: Object.values(reportAnswers).filter(Boolean) as ApiFormAnswer[], + isCompleted, + lastUpdatedAt: now, + // only for showing the report in the list until it is reloaded from the server + attachments: Object.entries(attachments).flatMap(([id, questionAttachments]) => + questionAttachments.map((attachment) => ({ + submissionId: incidentReportId, + questionId: id, + fileName: attachment.fileName, + mimeType: attachment.mimeType, + presignedUrl: attachment.uri, + urlValidityInSeconds: 0, + })), + ), + notes: Object.entries(notes).flatMap(([id, questionNotes]) => + questionNotes.map((note) => ({ + id: note.id, + submissionId: incidentReportId, + questionId: id, + text: note.text, + timeSubmitted: now, + })), + ), + }; + }; + + // saves the draft; queued (and sent in order with the notes) while offline + const saveDraft = (reportAnswers: Record) => { + if (!activeElectionRound || !currentForm || isSentRef.current) return; + saveReport(buildReportPayload(reportAnswers, false), { + onSuccess: () => { + isOnServerRef.current = true; + }, + }); + setIsSaved(true); + }; + + // media files reference the report, so it has to exist on the server before uploading one + const ensureReportOnServer = async () => { + if (isOnServerRef.current) return; + await saveReportAsync(buildReportPayload(answers, false)); + isOnServerRef.current = true; + }; + + const onSubmitLocation = () => { + saveDraft(answers); + const firstQuestion = findNextQuestion(-1, answers); + if (!firstQuestion) { + // a form without questions: nothing to fill in, the report can be sent right away + sendReport(answers); + return; + } + setQuestionId((current) => current ?? firstQuestion.id); + setStep("questions"); + }; + + // keeps the active question's answer and saves the draft when anything changed + const commitAnswer = (formValues: QuestionFormValues) => { + if (!activeQuestion) return answers; + const id = activeQuestion.question.id; + + const updatedAnswer = withAnswerTimestamps( + mapFormSubmissionDataToAPIFormSubmissionAnswer( + id, + activeQuestion.question.$questionType, + formValues[id], + ), + answers[id], + ); + + // drop the answers of dependent questions that are no longer displayed + const updatedAnswers = removeAnswersForHiddenQuestions(currentForm?.questions, { + ...answers, + [id]: updatedAnswer, + }) as Record; + + if (JSON.stringify(updatedAnswers) !== JSON.stringify(answers)) { + setAnswers(updatedAnswers); + saveDraft(updatedAnswers); + } + return updatedAnswers; + }; + + const onSubmitAnswer = (formValues: QuestionFormValues) => { + if (!activeQuestion) return; + const updatedAnswers = commitAnswer(formValues); + const nextQuestion = findNextQuestion(activeQuestion.indexInAllQuestions, updatedAnswers); + if (nextQuestion) { + setQuestionId(nextQuestion.id); + } + }; + + // the report can be sent at any point, in one tap (no review step: an observer in danger must be able + // to send what they have right away), with whatever was filled in so far + const onSendNow = (formValues: QuestionFormValues) => { + sendReport(commitAnswer(formValues)); + }; + + const onPreviousAnswer = (formValues: QuestionFormValues) => { + if (!activeQuestion) return; + const updatedAnswers = commitAnswer(formValues); + const prevQuestion = findPreviousQuestion(activeQuestion.indexInAllQuestions, updatedAnswers); + if (prevQuestion) { + setQuestionId(prevQuestion.id); + } else if (!isSaved) { + setStep("location"); + } + }; + + // never waits for the server: the save is queued (with the earlier ones) and sent as soon as there + // is a connection, retried while there is none, and kept if the app is closed while offline + const sendReport = (reportAnswers: Record) => { + if (!activeElectionRound || !currentForm || isSentRef.current) return; + isSentRef.current = true; + + Keyboard.dismiss(); + saveReport(buildReportPayload(reportAnswers, true)); + Toast.show({ + type: "success", + text2: isOnline ? t("sending.sent") : t("sending.queued"), + visibilityTime: 4000, + }); + router.back(); + }; + + const uploadNewAttachment = async (fileMetadata: FileMetadata) => { + if (!activeElectionRound || !currentForm || !questionId) return; + const attachmentQuestionId = questionId; + const attachmentId = Crypto.randomUUID(); + let uploadId: string | undefined; + + cancelRef.current = false; + setIsUploading(true); + setUploadProgress(t("upload.starting", { ns: "report_new_issue" })); + + try { + await ensureReportOnServer(); + + const numberOfUploadParts = Math.ceil(fileMetadata.size! / MULTIPART_FILE_UPLOAD_SIZE); + const upload = await initAttachment({ + id: attachmentId, + electionRoundId: activeElectionRound.id, + incidentReportId, + formId: currentForm.id, + questionId: attachmentQuestionId, + fileName: fileMetadata.name, + contentType: fileMetadata.type, + numberOfUploadParts, + }); + uploadId = upload.uploadId; + + let etags: Record = {}; + for (const [index, url] of Object.values(upload.uploadUrls).entries()) { + if (cancelRef.current) { + throw new Error("Upload aborted"); + } + const data = await uploadS3Chunk({ + url, + filePath: fileMetadata.uri, + partIndex: index, + partSize: MULTIPART_FILE_UPLOAD_SIZE, + }); + setUploadProgress( + `${t("upload.progress", { ns: "report_new_issue" })} ${Math.ceil(((index + 1) / numberOfUploadParts) * 100)}%`, + ); + etags = { ...etags, [index + 1]: data.ETag }; + } + + await completeAttachment({ + uploadId, + etags, + electionRoundId: activeElectionRound.id, + id: attachmentId, + incidentReportId, + }); + + setAttachments((current) => ({ + ...current, + [attachmentQuestionId]: [ + ...(current[attachmentQuestionId] || []), + { + id: attachmentId, + fileName: fileMetadata.name, + mimeType: fileMetadata.type, + uri: fileMetadata.uri, + }, + ], + })); + setIsOptionsSheetOpen(false); + queryClient.invalidateQueries({ + queryKey: IncidentReportKeys.attachments(activeElectionRound.id, incidentReportId), + }); + queryClient.invalidateQueries({ + queryKey: IncidentReportKeys.byElectionRound(activeElectionRound.id), + }); + } catch (err) { + Sentry.captureException(err, { data: { incidentReportId } }); + if (uploadId) { + abortAttachment({ + id: attachmentId, + uploadId, + electionRoundId: activeElectionRound.id, + incidentReportId, + }).catch(() => undefined); + } + if (!cancelRef.current) { + Toast.show({ type: "error", text2: t("media.error", { ns: "report_new_issue" }) }); + } + } finally { + setIsUploading(false); + setUploadProgress(""); + } + }; + + const onAbortUpload = () => { + cancelRef.current = true; + setIsUploading(false); + setIsOptionsSheetOpen(false); + setUploadProgress(""); + removeMutationByScopeId(queryClient, MUTATION_SCOPE_DO_NOT_HYDRATE); + }; + + const removeAttachment = async (id: string) => { + if (!activeElectionRound || !questionId) return; + if (!onlineManager.isOnline()) { + Toast.show({ + type: "error", + text2: t("upload.offline", { ns: "report_new_issue" }), + visibilityTime: 5000, + text2Style: { textAlign: "center" }, + }); + return; + } + const attachmentQuestionId = questionId; + try { + await deleteAttachmentOnServer({ + id, + electionRoundId: activeElectionRound.id, + incidentReportId, + }); + setAttachments((current) => ({ + ...current, + [attachmentQuestionId]: (current[attachmentQuestionId] || []).filter((a) => a.id !== id), + })); + } catch (err) { + Sentry.captureException(err); + Toast.show({ type: "error", text2: t("error") }); + } + }; + + const handleCameraUpload = async (type: "library" | "cameraPhoto") => { + setIsPreparingFile(true); + setUploadProgress(t("upload.preparing", { ns: "report_new_issue" })); + const cameraResult = await uploadCameraOrMedia(type, (progress: number) => + setUploadProgress( + `${t("upload.compressing", { ns: "report_new_issue" })} ${Math.ceil(progress * 100)}%`, + ), + ); + setIsPreparingFile(false); + if (cameraResult) { + await uploadNewAttachment(cameraResult); + } else { + setUploadProgress(""); + } + }; + + const handleUploadAudio = async () => { + const doc = await DocumentPicker.getDocumentAsync({ type: "audio/*", multiple: false }); + const file = doc?.assets?.[0]; + if (file) { + await uploadNewAttachment({ + name: file.name, + type: file.mimeType || "audio/mpeg", + uri: file.uri, + size: file.size || 0, + }); + } + }; + + const saveNote = (text: string) => { + if (!activeElectionRound || !currentForm || !questionId || !noteEditor) return; + const noteQuestionId = questionId; + const id = noteEditor.noteId ?? Crypto.randomUUID(); + + setNotes((current) => { + const questionNotes = current[noteQuestionId] || []; + return { + ...current, + [noteQuestionId]: noteEditor.noteId + ? questionNotes.map((note) => (note.id === id ? { ...note, text } : note)) + : [...questionNotes, { id, text }], + }; + }); + // shares the report's mutation scope: sent after the report, also when queued offline + saveNoteOnServer({ + id, + electionRoundId: activeElectionRound.id, + incidentReportId, + formId: currentForm.id, + questionId: noteQuestionId, + text, + }); + + Keyboard.dismiss(); + setNoteEditor(null); + setIsOptionsSheetOpen(false); + }; + + const removeNote = (id: string) => { + if (!activeElectionRound || !questionId) return; + const noteQuestionId = questionId; + setNotes((current) => ({ + ...current, + [noteQuestionId]: (current[noteQuestionId] || []).filter((note) => note.id !== id), + })); + deleteNoteOnServer({ id, electionRoundId: activeElectionRound.id, incidentReportId }); + }; + + const openNoteEditor = (note: ReportNote) => { + Keyboard.dismiss(); + setNoteEditor({ noteId: note.id, text: note.text }); + setIsOptionsSheetOpen(true); + }; + + const handleOnShowAttachmentSheet = () => { + Keyboard.dismiss(); + setIsOptionsSheetOpen(true); + }; + + // adding media needs a connection (the files are uploaded right away) + const whenOnline = (action: () => void) => () => { + if (isOnline) { + action(); + } else { + Toast.show({ + type: "error", + text2: t("upload.offline", { ns: "report_new_issue" }), + visibilityTime: 5000, + text2Style: { textAlign: "center" }, + }); + } + }; + + if (!formId) { + return Incorrect page params; + } + + if (isLoadingForm || (isResuming && isLoadingReport)) { + return ( + + + + ); + } + + if (!currentForm || (isResuming && !existingReport)) { + return ( + +
} + onLeftPress={() => router.back()} + /> + + {currentForm ? t("error", { ns: "report_details" }) : t("form_not_found")} + + + ); + } + + if (!isInitialized) { + return ( + + + + ); + } + + const questionAttachments = (questionId && attachments[questionId]) || []; + const questionNotes = (questionId && notes[questionId]) || []; + const isFirstQuestion = + !!activeQuestion && findPreviousQuestion(activeQuestion.indexInAllQuestions, answers) === null; + const isLastQuestion = + !!activeQuestion && findNextQuestion(activeQuestion.indexInAllQuestions, answers) === null; + // once saved, the location can not be changed any more + const showPrevious = !isFirstQuestion || !isSaved; + // the form menu, as on the other forms: send the report (from any question) and change the language + const hasFormMenu = step === "questions"; + const hasTranslations = (currentForm?.languages.length ?? 0) > 1; + + return ( + + ) : ( + + ) + } + > +
} + onLeftPress={handleGoBack} + rightIcon={hasFormMenu ? : undefined} + onRightPress={ + hasFormMenu + ? () => { + Keyboard.dismiss(); + setIsFormMenuOpen(true); + } + : undefined + } + /> + + {step === "location" && ( + + ( + + - - ); - }} - /> - {errors.noteText && {errors.noteText.message}} - - - - - + setAddingNote(false)} + /> ); }; diff --git a/mobile/components/Button.tsx b/mobile/components/Button.tsx index 33962538e..f31b1d6d7 100644 --- a/mobile/components/Button.tsx +++ b/mobile/components/Button.tsx @@ -1,80 +1,73 @@ -import React from "react"; -import { TextStyle } from "react-native"; -import { - Button as TamaguiButton, - ButtonProps as TamaguiButtonProps, - styled, - useTheme, -} from "tamagui"; +import React, { useState } from "react"; +import { GestureResponderEvent, TextStyle } from "react-native"; +import { GetProps, styled, useTheme, XStack } from "tamagui"; import { Typography } from "./Typography"; type PresetType = "default" | "outlined" | "chromeless" | "red" | "yellow"; -const StyledButton = styled(TamaguiButton, { - name: "StyledButton", +/* + Built on a plain XStack instead of tamagui's Button on purpose: on Android, tamagui's Button kept + its internal "pressed" state after the press ended and stayed drawn with the pressed theme + (faded, with a grey border, or grey like a disabled button) until the screen was left. + The pressed and disabled looks are therefore plain variants, driven by our own state and props. +*/ +const ButtonFrame = styled(XStack, { + name: "AppButton", + role: "button", + focusable: true, + flexDirection: "row", + flexWrap: "nowrap", + justifyContent: "center", + alignItems: "center", + gap: "$xs", + height: "$true", paddingHorizontal: "$md", paddingVertical: "$xs", borderRadius: 8, + borderWidth: 1, + borderColor: "transparent", backgroundColor: "$purple5", - alignItems: "center", - disabledStyle: { - backgroundColor: "$gray3", - }, variants: { presets: { - default: { - pressStyle: { - backgroundColor: "$purple5", - opacity: 0.8, - }, - }, + default: {}, outlined: { - borderWidth: 1, borderColor: "$purple5", backgroundColor: "transparent", - pressStyle: { - backgroundColor: "transparent", - opacity: 0.8, - borderColor: "$purple7", - }, - disabledStyle: { - backgroundColor: "transparent", - opacity: 0.5, - }, }, chromeless: { backgroundColor: "transparent", - pressStyle: { - backgroundColor: "transparent", - opacity: 0.8, - borderColor: "transparent", - }, - disabledStyle: { - backgroundColor: "transparent", - opacity: 0.5, - }, }, red: { backgroundColor: "$red10", - pressStyle: { - backgroundColor: "$red10", - opacity: 0.8, - }, }, yellow: { backgroundColor: "$yellow5", - color: "$purple6", - pressStyle: { - backgroundColor: "$yellow5", - opacity: 0.8, - borderColor: "$yellow5", - }, + }, + }, + isPressed: { + true: { + opacity: 0.8, + }, + }, + // filled presets turn grey, the transparent ones fade + disabledLook: { + filled: { + backgroundColor: "$gray3", + borderColor: "transparent", + }, + transparent: { + opacity: 0.5, }, }, } as const, }); -export interface ButtonProps extends TamaguiButtonProps { +type ButtonFrameProps = GetProps; + +export interface ButtonProps extends Omit< + ButtonFrameProps, + "presets" | "isPressed" | "disabledLook" | "children" +> { children: string; /** * One of the different types of button presets. @@ -84,19 +77,35 @@ export interface ButtonProps extends TamaguiButtonProps { * Optional styling for overriding presetType text styling */ textStyle?: TextStyle; + /** + * Shown before the text + */ + icon?: React.ReactNode | (() => React.ReactNode); + disabled?: boolean; } /** * Button components which supports 3 initial presets: filled, outlined and chromeless - * This component is a HOC over the Tamagui Button. * @param {ButtonProps} props - The props for the `Button` component. * @returns {JSX.Element} The rendered `Button` component. */ const Button = (props: ButtonProps): React.ReactElement => { const theme = useTheme(); + const [isPressed, setIsPressed] = useState(false); - const { style: $styleOverride, children, textStyle, ...rest } = props; - const presetType: PresetType = props.preset ?? "default"; + const { + style: $styleOverride, + children, + textStyle, + icon, + preset, + disabled, + onPress, + onPressIn, + onPressOut, + ...rest + } = props; + const presetType: PresetType = preset ?? "default"; const $presetTextStyles = { color: presetType === "default" || presetType === "red" ? "white" : theme.$purple5?.val, }; @@ -104,11 +113,44 @@ const Button = (props: ButtonProps): React.ReactElement => { const $textStyles: TextStyle = { ...$presetTextStyles, ...textStyle }; return ( - + { + // onPressOut is not always delivered on Android when the press re-renders the screen, + // onPress is, so the pressed look is also cleared here + setIsPressed(false); + onPress?.(e); + } + } + onPressIn={(e: GestureResponderEvent) => { + setIsPressed(true); + onPressIn?.(e); + }} + onPressOut={(e: GestureResponderEvent) => { + setIsPressed(false); + onPressOut?.(e); + }} + {...rest} + > + {typeof icon === "function" ? icon() : icon} {children} - + ); }; diff --git a/mobile/components/DateTimeSettingsScreen.tsx b/mobile/components/DateTimeSettingsScreen.tsx new file mode 100644 index 000000000..ce36a8141 --- /dev/null +++ b/mobile/components/DateTimeSettingsScreen.tsx @@ -0,0 +1,97 @@ +import React from "react"; +import { router } from "expo-router"; +import { useTranslation } from "react-i18next"; +import { XStack, YStack } from "tamagui"; +import { DATE_FORMATS, getDateFormatLabel, formatDate, formatTime } from "../common/utils/time"; +import { useDateTimeFormat } from "../contexts/date-time-format/DateTimeFormatContext.provider"; +import Card from "./Card"; +import Header from "./Header"; +import { Icon } from "./Icon"; +import { Screen } from "./Screen"; +import { Typography } from "./Typography"; + +interface SettingsOptionProps { + label: string; + example: string; + selected: boolean; + onPress: () => void; +} + +const SettingsOption = ({ label, example, selected, onPress }: SettingsOptionProps) => ( + + + + {label} + {example} + + {selected && } + + +); + +const DateTimeSettingsScreen = () => { + const { t } = useTranslation("dateTimeFormatSettings"); + const { is24Hour, setIs24Hour, dateFormat, setDateFormat } = useDateTimeFormat(); + + // a fixed afternoon shows the difference between the 12 and 24-hour clocks + const now = new Date(); + const afternoon = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 13, 30); + + return ( + +
} + onLeftPress={() => router.back()} + /> + + + + {t("time_format.title")} + + {[true, false].map((option) => ( + setIs24Hour(option)} + /> + ))} + + + + + {t("date_format.title")} + + {DATE_FORMATS.map((option) => ( + setDateFormat(option)} + /> + ))} + + + + ); +}; + +export default DateTimeSettingsScreen; diff --git a/mobile/components/FormQuestionListItem.tsx b/mobile/components/FormQuestionListItem.tsx index bc4826851..b6d5c663c 100644 --- a/mobile/components/FormQuestionListItem.tsx +++ b/mobile/components/FormQuestionListItem.tsx @@ -7,6 +7,7 @@ import { ApiFormAnswer } from "../services/interfaces/answer.type"; import QuestionCardFooter from "./QuestionCardFooter"; import { AttachmentMimeType } from "../services/api/get-attachments.api"; import { getAnswerDisplay } from "../common/utils/answers"; +import { useDateTimeFormat } from "../contexts/date-time-format/DateTimeFormatContext.provider"; export enum QuestionStatus { ANSWERED = "answered", @@ -42,6 +43,7 @@ const FormQuestionListItem = ({ onClick, }: FormQuestionListItemProps) => { const { t } = useTranslation("form_overview"); + const dateTimeFormat = useDateTimeFormat(); const QuestionStatusToTextWrapper = { [QuestionStatus.ANSWERED]: t("questions.answered"), @@ -62,7 +64,7 @@ const FormQuestionListItem = ({ {answer && ( - {getAnswerDisplay(answer)} + {getAnswerDisplay(answer, false, dateTimeFormat)} )} { const { t } = useTranslation(["form_submission_card", "common"]); + const { formatDate, formatTime } = useDateTimeFormat(); return ( @@ -57,7 +58,7 @@ const FormSubmissionListItem = ({ {t("last_updated_at")} - {format(lastUpdatedAt, "yyyy-MM-dd HH:mm")} + {`${formatDate(new Date(lastUpdatedAt))} ${formatTime(new Date(lastUpdatedAt))}`} {t("form_status")} diff --git a/mobile/components/Icon.tsx b/mobile/components/Icon.tsx index 1b18e4336..ff884a1c1 100644 --- a/mobile/components/Icon.tsx +++ b/mobile/components/Icon.tsx @@ -18,6 +18,7 @@ import MenuAlt2 from "../assets/icons/menu-alt-2.svg"; import DotsVertical from "../assets/icons/dots-vertical.svg"; import Check from "../assets/icons/check.svg"; import Calendar from "../assets/icons/Calendar.svg"; +import Clock from "../assets/icons/Clock.svg"; import PeopleAddingVote from "../assets/icons/people-adding-vote.svg"; import LoadingScreenDevice from "../assets/icons/loading-screen-device.svg"; import MissingPollingStation from "../assets/icons/missing-polling-station.svg"; @@ -126,6 +127,7 @@ export const defaultIcon = (props: IconProps): React.ReactElement => { dotsVertical: , check: , calendar: , + clock: , peopleAddingVote: , loadingScreenDevice: , missingPollingStation: , diff --git a/mobile/components/IncidentReportCard.tsx b/mobile/components/IncidentReportCard.tsx new file mode 100644 index 000000000..36a5a7c19 --- /dev/null +++ b/mobile/components/IncidentReportCard.tsx @@ -0,0 +1,73 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { XStack, YStack } from "tamagui"; +import Badge, { Status } from "./Badge"; +import Card from "./Card"; +import { Icon } from "./Icon"; +import { useDateTimeFormat } from "../contexts/date-time-format/DateTimeFormatContext.provider"; +import CardFooter from "./CardFooter"; +import { Typography } from "./Typography"; + +interface IncidentReportCardProps { + formName: string; + location: string; + numberOfAnswers: number; + numberOfAttachments: number; + // a draft (not completed) can still be continued + isCompleted: boolean; + // when it was sent (or, for a draft, last saved) + timestamp?: string; + onPress?: () => void; +} + +const IncidentReportCard = ({ + formName, + location, + numberOfAnswers, + numberOfAttachments, + isCompleted, + timestamp, + onPress, +}: IncidentReportCardProps): React.ReactElement => { + const { t } = useTranslation(["incident_report", "quick_report", "common"]); + const { formatDate, formatTime } = useDateTimeFormat(); + const date = timestamp ? new Date(timestamp) : undefined; + + return ( + + + + + {formName} + + + {isCompleted + ? t("status.completed", { ns: "common" }) + : t("status.in_progress", { ns: "common" })} + + + {date ? ( + + + {`${formatDate(date)} ${formatTime(date)}`} + + ) : ( + false + )} + {location ? {location} : false} + {t("list.answers", { count: numberOfAnswers })} + + + + ); +}; + +export default IncidentReportCard; diff --git a/mobile/components/Inputs/DateInput.tsx b/mobile/components/Inputs/DateInput.tsx index a71241843..437caf823 100644 --- a/mobile/components/Inputs/DateInput.tsx +++ b/mobile/components/Inputs/DateInput.tsx @@ -11,9 +11,15 @@ import Button from "../Button"; import { useLocalSearchParams } from "expo-router"; import { useTranslation } from "react-i18next"; import useAnimatedKeyboardPadding from "../../hooks/useAnimatedKeyboardPadding"; +import { useDateTimeFormat } from "../../contexts/date-time-format/DateTimeFormatContext.provider"; +import { getPickerLocale } from "../../common/utils/time"; + +export type DateInputMode = "date" | "time" | "datetime"; export interface DateInputProps extends XStackProps { value: Date | null; + // what is picked: a calendar day, a time of day, or both (the default) + mode?: DateInputMode; onChange: (...event: any[]) => void; minimumDate?: Date | null; maximumDate?: Date | null; @@ -21,6 +27,17 @@ export interface DateInputProps extends XStackProps { error?: string; } +const formatValue = ( + value: Date, + mode: DateInputMode, + formatDate: (value: Date) => string, + formatTime: (value: Date) => string, +) => { + if (mode === "date") return formatDate(value); + if (mode === "time") return formatTime(value); + return `${formatDate(value)} - ${formatTime(value)}`; +}; + export const DateInput: React.FC = ({ value, onChange, @@ -29,6 +46,7 @@ export const DateInput: React.FC = ({ placeholder, disabled, error, + mode = "datetime", ...rest }) => { const localParams = useLocalSearchParams(); @@ -36,7 +54,8 @@ export const DateInput: React.FC = ({ // on ios we use a temporary date, as the onChange function gets triggered every time the user picks a new date // therefore, we will update the FINAL date state (that comes from the outside), only onDonePress const [tempDate, setTempDate] = useState(value || new Date()); - const { t } = useTranslation("common"); + const { t, i18n } = useTranslation("common"); + const { is24Hour, formatDate, formatTime } = useDateTimeFormat(); const handleSheetOpen = () => { Keyboard.dismiss(); @@ -49,16 +68,23 @@ export const DateInput: React.FC = ({ } else { if (event.type === "set") { onClose(); + // android picks one part per dialog: a date or time question is done after its own dialog + if (mode !== "datetime") { + selectedDate && onChange(selectedDate); + return; + } DateTimePickerAndroid.open({ mode: "time", value: value || new Date(), onChange: (event, eventTime) => { if (eventTime && selectedDate) { selectedDate.setHours(eventTime.getHours()); - selectedDate.setMinutes(eventTime.getMinutes()); + // no seconds: picking the same date and time again is not a change to sync + selectedDate.setMinutes(eventTime.getMinutes(), 0, 0); onChange(selectedDate); } }, + is24Hour, }); } else if (event.type === "dismissed") { // press Cancel - close modal @@ -100,16 +126,7 @@ export const DateInput: React.FC = ({ numberOfLines={1} width="90%" > - {value - ? `${value.toLocaleDateString(["en-GB"], { - month: "2-digit", - day: "2-digit", - year: "numeric", - })} - ${value.toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - })}` - : placeholder} + {value ? formatValue(value, mode, formatDate, formatTime) : placeholder} {/* open bottom sheet on ios with date picker */} @@ -122,13 +139,16 @@ export const DateInput: React.FC = ({ @@ -137,10 +157,10 @@ export const DateInput: React.FC = ({ // open date picker modal on android open && DateTimePickerAndroid.open({ - mode: "date", + mode: mode === "time" ? "time" : "date", value: value || new Date(), onChange: onDateChange, - is24Hour: true, + is24Hour, minimumDate: minimumDate || undefined, maximumDate: maximumDate || undefined, }) diff --git a/mobile/components/MultiSubmissionFormList.tsx b/mobile/components/MultiSubmissionFormList.tsx index ca13e4004..f1e7ae92e 100644 --- a/mobile/components/MultiSubmissionFormList.tsx +++ b/mobile/components/MultiSubmissionFormList.tsx @@ -20,6 +20,7 @@ import { } from "../services/queries.service"; import { useFormSubmissions } from "../services/queries/form-submissions.query"; import { useElectionRoundAllForms } from "../services/queries/forms.query"; +import { isIncidentReportingForm } from "../services/definitions.api"; import Button from "./Button"; import { Dialog } from "./Dialog"; import MultiSubmissionFormCard from "./MultiSubmissionFormCard"; @@ -38,10 +39,7 @@ export type MultiSubmissionFormListItem = { }; type ListHeaderComponentType = - | ComponentType - | ReactElement> - | null - | undefined; + ComponentType | ReactElement> | null | undefined; interface IMultiSubmissionFormListProps { ListHeaderComponent: ListHeaderComponentType; @@ -93,7 +91,10 @@ const MultiSubmissionFormList = ({ ListHeaderComponent }: IMultiSubmissionFormLi }; const formList: MultiSubmissionFormListItem[] = useMemo(() => { - return mapFormToMultiSubmissionFormListItem(allForms?.forms, formSubmissions); + return mapFormToMultiSubmissionFormListItem( + allForms?.forms.filter((form) => !isIncidentReportingForm(form)), + formSubmissions, + ); }, [allForms, formSubmissions]); const { diff --git a/mobile/components/NoteForm.tsx b/mobile/components/NoteForm.tsx new file mode 100644 index 000000000..bb2d8eb41 --- /dev/null +++ b/mobile/components/NoteForm.tsx @@ -0,0 +1,83 @@ +import React from "react"; +import { Controller, useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { ScrollView, XStack, YStack } from "tamagui"; +import Button from "./Button"; +import Input from "./Inputs/Input"; +import { Typography } from "./Typography"; + +interface NoteFormProps { + heading: string; + initialText?: string; + onSave: (text: string) => void; + onCancel: () => void; +} + +const NoteForm = ({ heading, initialText = "", onSave, onCancel }: NoteFormProps) => { + const { t } = useTranslation(["polling_station_form_wizard", "common"]); + + const { + control, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ + defaultValues: { + noteText: initialText, + }, + }); + + const onSubmitNote = ({ noteText }: { noteText: string }) => { + onSave(noteText); + reset(); + }; + + return ( + + {heading} + + value.trim().length > 0 || t("notes.add.form.input.required"), + }} + render={({ field: { value: noteValue, onChange: onNoteChange } }) => { + return ( + + + + ); + }} + /> + {errors.noteText && {errors.noteText.message}} + + + + + + ); +}; + +export default NoteForm; diff --git a/mobile/components/NotificationListItem.tsx b/mobile/components/NotificationListItem.tsx index 2de36519f..ffa4d808a 100644 --- a/mobile/components/NotificationListItem.tsx +++ b/mobile/components/NotificationListItem.tsx @@ -4,10 +4,12 @@ import { useWindowDimensions, YStack } from "tamagui"; import { Typography } from "./Typography"; import { useTranslation } from "react-i18next"; import Card from "./Card"; +import { useDateTimeFormat } from "../contexts/date-time-format/DateTimeFormatContext.provider"; import RenderHtml from "react-native-render-html"; const NotificationListItem = ({ notification }: { notification: Notification }) => { - const { t, i18n } = useTranslation("inbox"); + const { t } = useTranslation("inbox"); + const { formatDate, formatTime } = useDateTimeFormat(); const sentAt = new Date(notification.sentAt); const isToday = sentAt.toDateString() === new Date().toDateString(); @@ -18,18 +20,8 @@ const NotificationListItem = ({ notification }: { notification: Notification }) {isToday - ? `${t("today")}, ` + - sentAt.toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - }) - : sentAt.toLocaleString(i18n.language, { - month: "short", - day: "2-digit", - year: "numeric", - hour: "2-digit", - minute: "2-digit", - })} + ? `${t("today")}, ${formatTime(sentAt)}` + : `${formatDate(sentAt)}, ${formatTime(sentAt)}`} { const { t } = useTranslation("observation"); + const { formatDate, formatTime } = useDateTimeFormat(); const handleNavigateToObservationTime = () => { router.push("/observation-time"); @@ -49,11 +51,7 @@ export const PSITime = ({ psiData }: { psiData?: PollingStationInformationAPIRes color={psiData.arrivalTime ? "$gray7" : "$gray3"} > {psiData.arrivalTime - ? new Date(psiData.arrivalTime).toLocaleDateString(["en-GB"], { - month: "2-digit", - day: "2-digit", - year: "numeric", - }) + ? formatDate(new Date(psiData.arrivalTime)) : t("polling_stations_information.observation_time.undefined_date")} @@ -63,10 +61,7 @@ export const PSITime = ({ psiData }: { psiData?: PollingStationInformationAPIRes color={psiData.arrivalTime ? "$gray7" : "$gray3"} > {psiData.arrivalTime - ? new Date(psiData.arrivalTime).toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - }) + ? formatTime(new Date(psiData.arrivalTime)) : t("polling_stations_information.observation_time.undefined_time")} @@ -85,11 +80,7 @@ export const PSITime = ({ psiData }: { psiData?: PollingStationInformationAPIRes color={psiData.departureTime ? "$gray7" : "$gray3"} > {psiData.departureTime - ? new Date(psiData.departureTime).toLocaleDateString(["en-GB"], { - month: "2-digit", - day: "2-digit", - year: "numeric", - }) + ? formatDate(new Date(psiData.departureTime)) : t("polling_stations_information.observation_time.undefined_date")} @@ -99,10 +90,7 @@ export const PSITime = ({ psiData }: { psiData?: PollingStationInformationAPIRes color={psiData.departureTime ? "$gray7" : "$gray3"} > {psiData.departureTime - ? new Date(psiData.departureTime).toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - }) + ? formatTime(new Date(psiData.departureTime)) : t("polling_stations_information.observation_time.undefined_time")} diff --git a/mobile/components/QuestionForm.tsx b/mobile/components/QuestionForm.tsx index 6bb395bca..cc4dd3569 100644 --- a/mobile/components/QuestionForm.tsx +++ b/mobile/components/QuestionForm.tsx @@ -11,6 +11,7 @@ import WizardRatingFormInput from "./WizardFormInputs/WizardRatingFormInput"; import { useTranslation } from "react-i18next"; import { ApiFormQuestion } from "../services/interfaces/question.type"; +import { FormQuestionAnswerTypeMapping } from "../services/interfaces/answer.type"; import { Typography } from "./Typography"; interface IQuestionFormProps { @@ -39,6 +40,9 @@ const QuestionForm = ({ const { t } = useTranslation("polling_station_form_wizard"); let returnedComponent: React.ReactNode; + // A question type this version of the app doesn't know can't be answered, so it must never block the form. + const supported = + !!activeQuestion && activeQuestion.question.$questionType in FormQuestionAnswerTypeMapping; return ( <> @@ -46,7 +50,7 @@ const QuestionForm = ({ key={activeQuestion?.question?.id} name={activeQuestion?.question?.id} rules={{ - required: required ? { value: true, message: t("form.required") } : false, + required: required && supported ? { value: true, message: t("form.required") } : false, }} control={control} render={({ field: { value, onChange }, fieldState: { error } }) => { @@ -91,6 +95,7 @@ const QuestionForm = ({ case "dateQuestion": returnedComponent = ( ); break; + case "timeQuestion": + returnedComponent = ( + + ); + break; + case "dateTimeQuestion": + returnedComponent = ( + + ); + break; case "singleSelectQuestion": returnedComponent = ( <> @@ -215,6 +244,17 @@ const QuestionForm = ({ /> ); break; + default: { + const unknown: ApiFormQuestion = activeQuestion.question; + returnedComponent = ( + + {t("form.unsupported_question")} + + ); + } } return ( diff --git a/mobile/components/ResourcesList.tsx b/mobile/components/ResourcesList.tsx index d531e5f27..a82db5535 100644 --- a/mobile/components/ResourcesList.tsx +++ b/mobile/components/ResourcesList.tsx @@ -10,6 +10,7 @@ import { useTranslation } from "react-i18next"; import Card, { CardProps } from "./Card"; import CardFooter from "./CardFooter"; import { useNetInfoContext } from "../contexts/net-info-banner/NetInfoContext"; +import { useDateTimeFormat } from "../contexts/date-time-format/DateTimeFormatContext.provider"; interface GuideCardProps extends CardProps { guide: Guide; @@ -18,6 +19,7 @@ interface GuideCardProps extends CardProps { const GuideCard = ({ guide, onResourcePress, ...rest }: GuideCardProps) => { const { t } = useTranslation(["guides", "resources"]); + const { formatDate, formatTime } = useDateTimeFormat(); const createdOn = new Date(guide.createdOn); const handleGuidePress = () => { @@ -46,7 +48,7 @@ const GuideCard = ({ guide, onResourcePress, ...rest }: GuideCardProps) => { )} {createdOn && ( )} diff --git a/mobile/components/ReviewCitizenFormSheet.tsx b/mobile/components/ReviewCitizenFormSheet.tsx index 99f105e8e..773456088 100644 --- a/mobile/components/ReviewCitizenFormSheet.tsx +++ b/mobile/components/ReviewCitizenFormSheet.tsx @@ -4,13 +4,9 @@ import { Typography } from "./Typography"; import Button from "./Button"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { FormAPIModel } from "../services/definitions.api"; -import { - ApiFormAnswer, - MultiSelectAnswer, - SingleSelectAnswer, -} from "../services/interfaces/answer.type"; +import { ApiFormAnswer } from "../services/interfaces/answer.type"; import { Separator, Sheet, Spinner, YStack } from "tamagui"; -import { getAnswerDisplay } from "../common/utils/answers"; +import { getAnswerDisplay, mapAnswersForDisplay } from "../common/utils/answers"; import { ApiFormQuestion } from "../services/interfaces/question.type"; import { useTranslation } from "react-i18next"; import * as Crypto from "expo-crypto"; @@ -26,8 +22,6 @@ import { useUploadAttachmentCitizenMutation, } from "../services/mutations/citizen/add-attachment-citizen.mutation"; import { AddAttachmentCitizenStartAPIPayload } from "../services/api/citizen/attachments.api"; -import * as FileSystem from "expo-file-system/legacy"; -import { Buffer } from "buffer"; import * as Sentry from "@sentry/react-native"; import MediaLoading from "./MediaLoading"; import { @@ -35,6 +29,7 @@ import { useUploadS3ChunkMutation, } from "../services/mutations/attachments/add-attachment.mutation"; import { useNetInfoContext } from "../contexts/net-info-banner/NetInfoContext"; +import { useDateTimeFormat } from "../contexts/date-time-format/DateTimeFormatContext.provider"; import { useQueryClient } from "@tanstack/react-query"; import { AttachmentData } from "../services/api/add-attachment.api"; @@ -72,6 +67,7 @@ export default function ReviewCitizenFormSheet({ }) { const cancelRef = useRef(false); const { t } = useTranslation("citizen_form"); + const dateTimeFormat = useDateTimeFormat(); const insets = useSafeAreaInsets(); const router = useRouter(); const { isOnline } = useNetInfoContext(); @@ -91,66 +87,10 @@ export default function ReviewCitizenFormSheet({ const { mutateAsync: addAttachmentCitizen } = useUploadAttachmentCitizenMutation(); - const mappedAnswers = useMemo(() => { - if (!answers || !questions) return {}; - - return Object.entries(answers).reduce( - (acc, [questionId, answer]) => { - if (!answer) return acc; - const question = questions.find((q) => q.id === questionId); - if (!question) return acc; - - let mappedAnswer = { ...answer } as SingleSelectAnswer | MultiSelectAnswer; - - if ( - answer.$answerType === "singleSelectAnswer" && - question.$questionType === "singleSelectQuestion" - ) { - const selectedOptionId = answer.selection.optionId; - - const selectedOptionText = question.options.find((o) => o.id === selectedOptionId)?.text[ - language - ]; - - mappedAnswer = { - ...mappedAnswer, - selection: { - ...mappedAnswer.selection, - value: selectedOptionText, - }, - } as SingleSelectAnswer; - } else if ( - answer.$answerType === "multiSelectAnswer" && - question.$questionType === "multiSelectQuestion" - ) { - const selectedOptionIds = answer.selection.map((selection) => selection.optionId); - const selectedOptionsTexts: string[] = []; - for (const option of question.options) { - if (selectedOptionIds.includes(option.id)) { - const selectedOption = answer.selection.find( - (selection) => selection.optionId === option.id, - ); - const optionText = option.text[language] || ""; - const userText = selectedOption?.text || ""; - selectedOptionsTexts.push( - userText ? `${optionText} (${userText.trim()})` : optionText, - ); - } - } - - mappedAnswer = { - ...mappedAnswer, - selection: answer.selection, - selectionValues: selectedOptionsTexts, - } as MultiSelectAnswer; - } - - acc[questionId] = mappedAnswer; - return acc; - }, - {} as Record, - ); - }, [answers, questions]); + const mappedAnswers = useMemo( + () => mapAnswersForDisplay(answers, questions, language), + [answers, questions, language], + ); // only display the questions that have an answer to them (because in case of skip logic we would have multiple questions that were not shown to the user) const displayedQuestions = useMemo(() => { if (!currentForm?.questions || !mappedAnswers) return []; @@ -296,14 +236,12 @@ export default function ReviewCitizenFormSheet({ if (cancelRef.current) { throw new Error("Upload aborted"); } - const chunk = await FileSystem.readAsStringAsync(filePath, { - length: MULTIPART_FILE_UPLOAD_SIZE, - position: index * MULTIPART_FILE_UPLOAD_SIZE, - encoding: FileSystem.EncodingType.Base64, + const data = await uploadS3Chunk({ + url, + filePath, + partIndex: index, + partSize: MULTIPART_FILE_UPLOAD_SIZE, }); - const buffer = Buffer.from(chunk, "base64"); - - const data = await uploadS3Chunk({ url, chunk: buffer }); setUploadProgress( `${t("attachments.upload.progress")} ${Math.ceil(((uploadedPartsNo + index) / totalParts) * 100)}%`, @@ -403,7 +341,11 @@ export default function ReviewCitizenFormSheet({ {question.text[language]} {mappedAnswers && mappedAnswers[question.id] && ( - {getAnswerDisplay(mappedAnswers[question.id] as ApiFormAnswer, true)} + {getAnswerDisplay( + mappedAnswers[question.id] as ApiFormAnswer, + true, + dateTimeFormat, + )} )} {attachments && attachments[question.id] ? ( diff --git a/mobile/components/SelectFormLanguageDialog.tsx b/mobile/components/SelectFormLanguageDialog.tsx new file mode 100644 index 000000000..151f07164 --- /dev/null +++ b/mobile/components/SelectFormLanguageDialog.tsx @@ -0,0 +1,64 @@ +import React from "react"; +import { Controller, useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { XStack } from "tamagui"; +import Button from "./Button"; +import { Dialog } from "./Dialog"; +import SelectFormLanguageDialogContent from "./SelectFormLanguageDialogContent"; +import { Typography } from "./Typography"; + +interface SelectFormLanguageDialogProps { + languages: string[]; + onConfirm: (language: string) => void; + onCancel: () => void; +} + +// asks which language to fill in a form that is available in several languages +const SelectFormLanguageDialog = ({ + languages, + onConfirm, + onCancel, +}: SelectFormLanguageDialogProps) => { + const { t } = useTranslation(["observation", "common"]); + const { + control, + handleSubmit, + formState: { errors }, + } = useForm<{ language: string }>({ defaultValues: { language: "" } }); + + return ( + ( + {t("forms.select_language_modal.header")} + } + content={ + + } + footer={ + + + + + } + /> + )} + /> + ); +}; + +export default SelectFormLanguageDialog; diff --git a/mobile/components/SelectPollingStation.tsx b/mobile/components/SelectPollingStation.tsx index 431d2a2dd..581c98e55 100644 --- a/mobile/components/SelectPollingStation.tsx +++ b/mobile/components/SelectPollingStation.tsx @@ -108,7 +108,6 @@ const SelectPollingStation = () => { )} diff --git a/mobile/contexts/date-time-format/DateTimeFormatContext.provider.tsx b/mobile/contexts/date-time-format/DateTimeFormatContext.provider.tsx new file mode 100644 index 000000000..ea5d18149 --- /dev/null +++ b/mobile/contexts/date-time-format/DateTimeFormatContext.provider.tsx @@ -0,0 +1,73 @@ +import { createContext, useContext, useMemo, useState } from "react"; +import { SECURE_STORAGE_KEYS } from "../../common/constants"; +import { + DATE_FORMATS, + DateFormat, + DEFAULT_DATE_FORMAT, + formatDate, + formatTime, +} from "../../common/utils/time"; +import { getSecureStoreItem, setSecureStoreItem } from "../../helpers/SecureStoreWrapper"; + +type DateTimeFormatContextType = { + // times are shown on a 24-hour clock unless the user picked the 12-hour one in settings + is24Hour: boolean; + setIs24Hour: (is24Hour: boolean) => void; + // dates are shown as YYYY-MM-DD unless the user picked another format in settings + dateFormat: DateFormat; + setDateFormat: (dateFormat: DateFormat) => void; + formatTime: (value: Date) => string; + formatDate: (value: Date) => string; +}; + +export const DateTimeFormatContext = createContext({ + is24Hour: true, + setIs24Hour: (_is24Hour: boolean) => {}, + dateFormat: DEFAULT_DATE_FORMAT, + setDateFormat: (_dateFormat: DateFormat) => {}, + formatTime: (value: Date) => formatTime(value, true), + formatDate: (value: Date) => formatDate(value, DEFAULT_DATE_FORMAT), +}); + +const getStoredDateFormat = (): DateFormat => { + const stored = getSecureStoreItem(SECURE_STORAGE_KEYS.DATE_FORMAT); + return DATE_FORMATS.find((dateFormat) => dateFormat === stored) ?? DEFAULT_DATE_FORMAT; +}; + +const DateTimeFormatContextProvider = ({ children }: React.PropsWithChildren) => { + const [is24Hour, setIs24HourState] = useState( + () => getSecureStoreItem(SECURE_STORAGE_KEYS.CLOCK_FORMAT) !== "12h", + ); + const [dateFormat, setDateFormatState] = useState(getStoredDateFormat); + + const setIs24Hour = (value: boolean) => { + setIs24HourState(value); + setSecureStoreItem(SECURE_STORAGE_KEYS.CLOCK_FORMAT, value ? "24h" : "12h"); + }; + + const setDateFormat = (value: DateFormat) => { + setDateFormatState(value); + setSecureStoreItem(SECURE_STORAGE_KEYS.DATE_FORMAT, value); + }; + + // a new value only when a format changes, so consumers don't re-render with every root render + const value = useMemo( + () => ({ + is24Hour, + setIs24Hour, + dateFormat, + setDateFormat, + formatTime: (date: Date) => formatTime(date, is24Hour), + formatDate: (date: Date) => formatDate(date, dateFormat), + }), + [is24Hour, dateFormat], + ); + + return {children}; +}; + +export const useDateTimeFormat = () => { + return useContext(DateTimeFormatContext); +}; + +export default DateTimeFormatContextProvider; diff --git a/mobile/contexts/persist-query/PersistQueryContext.provider.tsx b/mobile/contexts/persist-query/PersistQueryContext.provider.tsx index d9d9c435c..1c05a5918 100644 --- a/mobile/contexts/persist-query/PersistQueryContext.provider.tsx +++ b/mobile/contexts/persist-query/PersistQueryContext.provider.tsx @@ -4,12 +4,14 @@ import { QueryClient, defaultShouldDehydrateMutation, defaultShouldDehydrateQuery, + matchMutation, } from "@tanstack/react-query"; import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { notesKeys, pollingStationsKeys } from "../../services/queries.service"; import * as API from "../../services/definitions.api"; import { PersistGate } from "../../components/PersistGate"; +import { withoutRequestErrors } from "./persisted-client"; import { deleteAttachment } from "../../services/api/delete-attachment.api"; import { Note } from "../../common/models/note"; import { QuickReportKeys } from "../../services/queries/quick-reports.query"; @@ -23,7 +25,18 @@ import { AttachmentsKeys } from "../../services/queries/attachments.query"; import { ASYNC_STORAGE_KEYS, MUTATION_SCOPE_DO_NOT_HYDRATE } from "../../common/constants"; import * as Sentry from "@sentry/react-native"; import SuperJSON from "superjson"; +import Toast from "react-native-toast-message"; +import i18n from "../../common/config/i18n"; import { QuickReportAttachmentAPIResponse } from "../../services/api/quick-report/get-quick-reports.api"; +import { IncidentReportKeys } from "../../services/queries/incident-reports.query"; +import { + upsertIncidentReport, + upsertIncidentReportNote, + UpsertIncidentReportNoteAPIPayload, + deleteIncidentReportNote, + DeleteIncidentReportNoteAPIPayload, +} from "../../services/api/incident-report/incident-report.api"; +import { AddIncidentReportMutationPayload } from "../../services/mutations/incident-report/add-incident-report.mutation"; const queryClient = new QueryClient({ mutationCache: new MutationCache({ @@ -38,6 +51,19 @@ const queryClient = new QueryClient({ SuperJSON.stringify(mutation), ); Sentry.captureException(error); + + // "Send report" already told the observer the report was sent: say that it was not after all + const payload = mutation.state.variables as AddIncidentReportMutationPayload | undefined; + if ( + matchMutation({ mutationKey: IncidentReportKeys.add() }, mutation) && + payload?.isCompleted + ) { + Toast.show({ + type: "error", + text2: i18n.t("sending.failed", { ns: "incident_report" }), + visibilityTime: 8000, + }); + } }, }), defaultOptions: { @@ -90,6 +116,7 @@ const persister = createAsyncStoragePersister({ storage: AsyncStorage, throttleTime: 1000, key: ASYNC_STORAGE_KEYS.REACT_QUERY_OFFLINE_CACHE, + serialize: (client) => JSON.stringify(withoutRequestErrors(client)), }); const PersistQueryContextProvider = ({ children }: React.PropsWithChildren) => { @@ -144,6 +171,28 @@ const PersistQueryContextProvider = ({ children }: React.PropsWithChildren) => { }, }); + queryClient.setMutationDefaults(IncidentReportKeys.add(), { + mutationFn: async ({ + attachments: _attachments, + notes: _notes, + ...payload + }: AddIncidentReportMutationPayload) => { + return upsertIncidentReport(payload); + }, + }); + + queryClient.setMutationDefaults(IncidentReportKeys.addNote(), { + mutationFn: (payload: UpsertIncidentReportNoteAPIPayload) => { + return upsertIncidentReportNote(payload); + }, + }); + + queryClient.setMutationDefaults(IncidentReportKeys.deleteNote(), { + mutationFn: (payload: DeleteIncidentReportNoteAPIPayload) => { + return deleteIncidentReportNote(payload); + }, + }); + const runPendingMutations = async () => { console.log( "PersistQueryClientProvider onSuccess - Successfully get data from AsyncStorage and put in MutationCache", @@ -219,7 +268,12 @@ const PersistQueryContextProvider = ({ children }: React.PropsWithChildren) => { // if (query.meta?.dontPersist) return false; - return defaultShouldDehydrateQuery(query); + // a refetch that failed while offline (status "error") still has the data from before: keep it, + // otherwise starting the app offline a second time finds nothing (no election rounds, visits, ...) + return ( + defaultShouldDehydrateQuery(query) || + (query.state.status === "error" && query.state.data !== undefined) + ); }, shouldDehydrateMutation: (mutation) => { if (mutation.options.scope?.id === MUTATION_SCOPE_DO_NOT_HYDRATE) { diff --git a/mobile/contexts/persist-query/persisted-client.ts b/mobile/contexts/persist-query/persisted-client.ts new file mode 100644 index 000000000..00ecfc21e --- /dev/null +++ b/mobile/contexts/persist-query/persisted-client.ts @@ -0,0 +1,34 @@ +import type { PersistedClient } from "@tanstack/react-query-persist-client"; + +/* + What is written to storage, without the errors of failed requests: + - a query whose last refetch failed but that still has its data (kept so the app starts offline with + data) is stored as a success, so after a restart screens show the data and not an error + - a paused mutation keeps its payload but not the error of its last attempt + The errors are AxiosErrors: serialized, they carry the request config, auth headers included. +*/ +export const withoutRequestErrors = (client: PersistedClient): PersistedClient => ({ + ...client, + clientState: { + ...client.clientState, + queries: client.clientState.queries.map((query) => + query.state.status === "error" && query.state.data !== undefined + ? { + ...query, + state: { + ...query.state, + status: "success", + error: null, + errorUpdateCount: 0, + fetchFailureCount: 0, + fetchFailureReason: null, + }, + } + : query, + ), + mutations: client.clientState.mutations.map((mutation) => ({ + ...mutation, + state: { ...mutation.state, error: null, failureReason: null }, + })), + }, +}); diff --git a/mobile/helpers/incidentReport.ts b/mobile/helpers/incidentReport.ts new file mode 100644 index 000000000..9902d2885 --- /dev/null +++ b/mobile/helpers/incidentReport.ts @@ -0,0 +1,49 @@ +import i18n from "../common/config/i18n"; +import { PollingStationVisitVM } from "../common/models/polling-station.model"; +import { + IncidentReportAPIResponse, + IncidentReportLocationType, +} from "../services/api/incident-report/incident-report.api"; +import { FormAPIModel } from "../services/definitions.api"; + +// the app language if the form has it, otherwise the form default language +export const getFormLanguage = (form: FormAPIModel | undefined): string => { + const appLanguage = i18n.language.toUpperCase(); + if (!form || form.languages.includes(appLanguage)) return appLanguage; + return form.defaultLanguage || form.languages[0]; +}; + +export const getFormDisplayName = ( + form: FormAPIModel | undefined, + language: string = getFormLanguage(form), +): string => { + if (!form) return ""; + const name = + form.name[language] || form.name[form.defaultLanguage] || Object.values(form.name)[0]; + return `${form.code} - ${name}`; +}; + +export const getIncidentReportLocationLabel = ( + report: Pick< + IncidentReportAPIResponse, + "locationType" | "pollingStationId" | "locationDescription" + >, + visits: PollingStationVisitVM[] | undefined, +): string => { + switch (report.locationType) { + case IncidentReportLocationType.PollingStation: { + const visit = visits?.find((v) => v.pollingStationId === report.pollingStationId); + return visit ? `${visit.number} - ${visit.address}` : ""; + } + case IncidentReportLocationType.OtherPollingStation: + case IncidentReportLocationType.NotRelatedToAPollingStation: { + const label = + report.locationType === IncidentReportLocationType.OtherPollingStation + ? i18n.t("form.polling_station_id.options.other", { ns: "report_new_issue" }) + : i18n.t("form.polling_station_id.options.not_related", { ns: "report_new_issue" }); + return report.locationDescription ? `${label}: ${report.locationDescription}` : label; + } + default: + return ""; + } +}; diff --git a/mobile/hooks/useCamera.tsx b/mobile/hooks/useCamera.tsx index 42b4af039..27ce9f3e6 100644 --- a/mobile/hooks/useCamera.tsx +++ b/mobile/hooks/useCamera.tsx @@ -78,7 +78,10 @@ export const useCamera = () => { const result = await luncher({ ...(specifiedMediaType || { mediaTypes: ImagePicker.MediaTypeOptions.All }), - quality: 0.1, + // Keep at 1: any value below 1 makes the picker decode the full-resolution photo into + // a bitmap on Android (hundreds of MB on 50–200MP cameras) and crashes with OOM. + // Image.compress below does a downsampled decode instead and handles the size. + quality: 1, allowsMultipleSelection: false, videoQuality: ImagePicker.UIImagePickerControllerQualityType.Low, // TODO: careful here, Medium might be enough cameraType: ImagePicker.CameraType.back, diff --git a/mobile/hooks/useSettledValue.ts b/mobile/hooks/useSettledValue.ts new file mode 100644 index 000000000..da63f626b --- /dev/null +++ b/mobile/hooks/useSettledValue.ts @@ -0,0 +1,40 @@ +import { useCallback, useRef, useState } from "react"; +import { useFocusEffect, useNavigation } from "expo-router"; + +// how long to wait for the stack's transitionEnd before showing the latest value anyway +const FALLBACK_MS = 1000; + +/** + * Returns `value` as it was when this screen was last settled: while another screen covers it, and until the + * transition that reveals it again has ended, the previous value is kept. + * + * On Android (Fabric), changing the shape of a screen's view tree while react-native-screens re-attaches it during a + * pop crashes the app with "addViewAt: failed to insert view". That happens when the screen on top saves and goes + * back: the save updates the query cache this screen renders (optimistic update, then the response and the refetch) + * in the middle of the animation. Holding the value back until the screen is settled moves that change after it. + */ +export function useSettledValue(value: T): T { + const navigation = useNavigation(); + const [settled, setSettled] = useState(true); + const shown = useRef(value); + + useFocusEffect( + useCallback(() => { + // focus comes when the pop starts; the transition events belong to the route in the enclosing stack (this + // screen can sit inside tabs and a drawer) + let stack: any = navigation; + while (stack && stack.getState?.()?.type !== "stack") stack = stack.getParent?.(); + const settle = () => setSettled(true); + const timer = setTimeout(settle, FALLBACK_MS); + const unsubscribe = stack?.addListener?.("transitionEnd", settle); + return () => { + clearTimeout(timer); + unsubscribe?.(); + setSettled(false); + }; + }, [navigation]), + ); + + if (settled) shown.current = value; + return shown.current; +} diff --git a/mobile/package-lock.json b/mobile/package-lock.json index 58917d774..49ff0a535 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -47,6 +47,7 @@ "expo-notifications": "~0.32.17", "expo-router": "~6.0.24", "expo-secure-store": "~15.0.8", + "expo-splash-screen": "~31.0.13", "expo-status-bar": "~3.0.9", "expo-system-ui": "~6.0.9", "expo-updates": "~29.0.19", @@ -144,6 +145,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -1546,6 +1548,7 @@ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz", "integrity": "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==", "license": "MIT", + "peer": true, "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -3757,7 +3760,8 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/@jsamr/counter-style/-/counter-style-2.0.2.tgz", "integrity": "sha512-2mXudGVtSzVxWEA7B9jZLKjoXUeUFYDDtFrQoC0IFX9/Dszz4t1vZOmafi3JSw/FxD+udMQ+4TAFR8Qs0J3URQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@jsamr/react-native-li": { "version": "2.3.1", @@ -4896,6 +4900,7 @@ "resolved": "https://registry.npmjs.org/@react-navigation/drawer/-/drawer-7.13.4.tgz", "integrity": "sha512-68CIOavWFDt6rUPEo9ELap5e3I09iFMWQVXNPANVrrCEHhjegwaNfZTwaSTQN43IbATFsgkOON4T7Hh/skq9dA==", "license": "MIT", + "peer": true, "dependencies": { "@react-navigation/elements": "^2.9.35", "color": "^4.2.3", @@ -4940,6 +4945,7 @@ "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.3.13.tgz", "integrity": "sha512-eiNUieNCJxCdHSVBUJWwoACspqlUGULHj36Z8CQRlymwPxOD8fXA23vXAqdWJ6VT3MmVh7akLLPvs9aKMArgxg==", "license": "MIT", + "peer": true, "dependencies": { "@react-navigation/core": "^7.21.10", "escape-string-regexp": "^4.0.0", @@ -5497,6 +5503,7 @@ "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "^7.21.3", "@svgr/babel-preset": "8.1.0", @@ -7144,6 +7151,7 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz", "integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==", "license": "MIT", + "peer": true, "dependencies": { "@tanstack/query-core": "5.101.4" }, @@ -7288,6 +7296,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.17.tgz", "integrity": "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -7297,7 +7306,6 @@ "resolved": "https://registry.npmjs.org/@types/react-native/-/react-native-0.72.8.tgz", "integrity": "sha512-St6xA7+EoHN5mEYfdWnfYt0e8u6k2FR0P9s2arYgakQGFgU1f9FlPrIEcj0X24pLCF5c5i3WVuLCUdiCYHmOoA==", "license": "MIT", - "peer": true, "dependencies": { "@react-native/virtualized-lists": "^0.72.4", "@types/react": "*" @@ -7308,7 +7316,6 @@ "resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.72.8.tgz", "integrity": "sha512-J3Q4Bkuo99k7mu+jPS9gSUSgq+lLRSI/+ahXNwV92XgJ/8UgOTxu2LPwhJnBk/sQKxq7E8WkZBnBiozukQMqrw==", "license": "MIT", - "peer": true, "dependencies": { "invariant": "^2.2.4", "nullthrows": "^1.1.1" @@ -7384,6 +7391,7 @@ "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "7.18.0", "@typescript-eslint/types": "7.18.0", @@ -7625,6 +7633,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -8488,6 +8497,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.44", "caniuse-lite": "^1.0.30001806", @@ -9968,6 +9978,7 @@ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -10049,6 +10060,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -10249,6 +10261,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -10330,6 +10343,7 @@ "integrity": "sha512-6TyDmZ1HXoFQXnhCTUjVFULReoBPOAjpuiKELMkeP40yffI/1ZRO+d9ug/VC6fqISo2WkuIBk3cvuRPALaWlOQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "builtins": "^5.0.1", @@ -10418,6 +10432,7 @@ "integrity": "sha512-57Zzfw8G6+Gq7axm2Pdo3gW/Rx3h9Yywgn61uE/3elTCOePEHVrn2i5CdfBwA1BLK0Q0WqctICIUSqXZW/VprQ==", "dev": true, "license": "ISC", + "peer": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, @@ -10728,6 +10743,7 @@ "resolved": "https://registry.npmjs.org/expo/-/expo-54.0.36.tgz", "integrity": "sha512-HMHp1H+actmnX85NJE6lILKzSJV6pTDNkwghq9EMOP3zTynjvBYVqJGSLlm6sEVzJCC5Z2ZiKgLvtsHrqlY0dg==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "54.0.26", @@ -10862,6 +10878,7 @@ "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz", "integrity": "sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ==", "license": "MIT", + "peer": true, "dependencies": { "@expo/config": "~12.0.13", "@expo/env": "~2.0.8" @@ -10998,6 +11015,7 @@ "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.12.tgz", "integrity": "sha512-QQzunE2Mxk45AsCWm3tK7OpVljbtVnKD58q4/qliev+cbye1IOduUnRIdD+P7DyButw17G9MTX795kgaQiz5hQ==", "license": "MIT", + "peer": true, "dependencies": { "fontfaceobserver": "^2.1.0" }, @@ -11077,6 +11095,7 @@ "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.12.tgz", "integrity": "sha512-FpXeIpFgZuxihwT9lBo86YD3y6LphBuAhN680MMxm/Y7fmsc57vimn2d3vFu68VI0+Z9w457t494mu2wvlgWTQ==", "license": "MIT", + "peer": true, "dependencies": { "expo-constants": "~18.0.13", "invariant": "^2.2.4" @@ -11314,6 +11333,18 @@ "node": ">=20.16.0" } }, + "node_modules/expo-splash-screen": { + "version": "31.0.13", + "resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-31.0.13.tgz", + "integrity": "sha512-1epJLC1cDlwwj089R2h8cxaU5uk4ONVAC+vzGiTZH4YARQhL4Stlz1MbR6yAS173GMosvkE6CAeihR7oIbCkDA==", + "license": "MIT", + "dependencies": { + "@expo/prebuild-config": "^54.0.8" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-status-bar": { "version": "3.0.9", "resolved": "https://registry.npmjs.org/expo-status-bar/-/expo-status-bar-3.0.9.tgz", @@ -12505,6 +12536,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.23.2" } @@ -16307,6 +16339,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -16347,6 +16380,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -16377,6 +16411,7 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.82.0.tgz", "integrity": "sha512-Zw/uFZ2dO+02GHlBn7JFGn8kZJ7LdM33B/0BXOovzFay+CMhf94JMw5BVu+F1tVkUKjNvBuaE3fz5BJhga10Tg==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -16421,6 +16456,7 @@ "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.81.5.tgz", "integrity": "sha512-1w+/oSjEXZjMqsIvmkCRsOc8UBYv163bTWKTI8+1mxztvQPhCRYGTvZ/PL1w16xXHneIj/SLGfxWg2GWN2uexw==", "license": "MIT", + "peer": true, "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native/assets-registry": "0.81.5", @@ -16510,6 +16546,7 @@ "resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.28.0.tgz", "integrity": "sha512-0msfJ1vRxXKVgTgvL+1ZOoYw3/0z1R+Ked0+udoJhyplC2jbVKIJ8Z1bzWdpQRCV3QcQ87Op0zJVE5DhKK2A0A==", "license": "MIT", + "peer": true, "dependencies": { "@egjs/hammerjs": "^2.0.17", "hoist-non-react-statics": "^3.3.0", @@ -16591,6 +16628,7 @@ "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.7.tgz", "integrity": "sha512-Q4H6xA3Tn7QL0/E/KjI86I1KK4tcf+ErRE04LH34Etka2oVQhW6oXQ+Q8ZcDCVxiWp5vgbBH6XcH8BOo4w/Rhg==", "license": "MIT", + "peer": true, "dependencies": { "react-native-is-edge-to-edge": "^1.2.1", "semver": "^7.7.2" @@ -16639,6 +16677,7 @@ "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz", "integrity": "sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg==", "license": "MIT", + "peer": true, "peerDependencies": { "react": "*", "react-native": "*" @@ -16649,6 +16688,7 @@ "resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.16.0.tgz", "integrity": "sha512-yIAyh7F/9uWkOzCi1/2FqvNvK6Wb9Y1+Kzn16SuGfN9YFJDTbwlzGRvePCNTOX0recpLQF3kc2FmvMUhyTCH1Q==", "license": "MIT", + "peer": true, "dependencies": { "react-freeze": "^1.0.0", "react-native-is-edge-to-edge": "^1.2.1", @@ -16664,6 +16704,7 @@ "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.12.1.tgz", "integrity": "sha512-vCuZJDf8a5aNC2dlMovEv4Z0jjEUET53lm/iILFnFewa15b4atjVxU6Wirm6O9y6dEsdjDZVD7Q3QM4T1wlI8g==", "license": "MIT", + "peer": true, "dependencies": { "css-select": "^5.1.0", "css-tree": "^1.1.3", @@ -16853,6 +16894,7 @@ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -16932,6 +16974,7 @@ "integrity": "sha512-mOzkc1Rb4sN98OLY/frkP0e1A60bTFjuXUgG8D4m+pwCcjVCWZupdSFV8c5+gqcZBnreoVR20/ko6D8X/7KPvg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "reactotron-core-contract": "0.4.0" } @@ -18865,6 +18908,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/mobile/package.json b/mobile/package.json index 439179957..70fd15fa1 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -20,7 +20,8 @@ "eas-update-production": "dotenv -e .env.production -- node scripts/publish-update.js", "eas-update": "dotenv -e .env -- node scripts/publish-update.js", "build-and-bump": "dotenv -e .env -- node scripts/build.js", - "postinstall": "patch-package" + "postinstall": "patch-package", + "test": "node --test \"tests/**/*.test.ts\"" }, "dependencies": { "@hookform/resolvers": "^3.3.4", @@ -61,6 +62,7 @@ "expo-notifications": "~0.32.17", "expo-router": "~6.0.24", "expo-secure-store": "~15.0.8", + "expo-splash-screen": "~31.0.13", "expo-status-bar": "~3.0.9", "expo-system-ui": "~6.0.9", "expo-updates": "~29.0.19", diff --git a/mobile/services/api-errors.ts b/mobile/services/api-errors.ts new file mode 100644 index 000000000..ab4e91ed0 --- /dev/null +++ b/mobile/services/api-errors.ts @@ -0,0 +1,51 @@ +import axios from "axios"; + +const UUID_REGEX = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi; + +// Replace ids so the same endpoint is grouped into a single Sentry issue +export const normalizeUrl = (url?: string) => + (url ?? "").split("?")[0].replace(UUID_REGEX, "{id}").replace(/^\//, ""); + +/** + * Endpoints that answer 404 when there is nothing to fetch (e.g. a round without citizen reporting forms, + * locations or a polling station information form). Anywhere else a 404 is a real error and is reported. + */ +const EXPECTED_NOT_FOUND = new Set([ + "election-rounds/{id}/citizen-reporting-forms", + "election-rounds/{id}/locations:version", + "election-rounds/{id}/locations:fetchAll", + "election-rounds/{id}/polling-stations:version", + "election-rounds/{id}/polling-stations:fetchAll", + "election-rounds/{id}/polling-station-information-form", +]); + +// the request got no response: no connectivity, an unreachable server or a timeout +export const isNetworkError = (error: unknown) => + axios.isAxiosError(error) && !error.response && !axios.isCancel(error); + +/** + * Timeouts and requests without a response (no connectivity) are expected on observers' phones, and + * the queries retry them; so is a 404 from an endpoint where it means "nothing there" (EXPECTED_NOT_FOUND). + * Reporting those only buries real errors: Sentry's beforeSend drops them wherever they're captured. + */ +export const isExpectedApiError = (error: unknown) => { + if (axios.isAxiosError(error)) { + const status = error.response?.status; + if (status === 404) return EXPECTED_NOT_FOUND.has(normalizeUrl(error.config?.url)); + return !status && !!error.request; + } + // fetch (uploads, push tokens) without connectivity + return error instanceof TypeError && error.message === "Network request failed"; +}; + +/* + Incident report saves (the report and its notes) must not be lost when the connection drops: a request + that got no response is retried with a growing delay. While offline the retries are paused (that does + not use them up); with a connection but no answer (server down, timeouts) they stop after + MAX_NETWORK_RETRIES (~3.5 minutes with retryDelay), so the saves queued behind it in the report's scope (notes) are + not blocked forever. A send that fails for good is shown to the observer (PersistQueryContext). + Errors the server answered (e.g. a locked report) are not retried. +*/ +const MAX_NETWORK_RETRIES = 10; +export const retryWhileNoConnection = (failureCount: number, error: unknown) => + isNetworkError(error) && failureCount < MAX_NETWORK_RETRIES; diff --git a/mobile/services/api.ts b/mobile/services/api.ts index 9997236c8..4e5d41c07 100644 --- a/mobile/services/api.ts +++ b/mobile/services/api.ts @@ -1,4 +1,5 @@ import axios, { AxiosError, AxiosRequestHeaders } from "axios"; +import { isExpectedApiError, isNetworkError, normalizeUrl } from "./api-errors"; import * as Sentry from "@sentry/react-native"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { ASYNC_STORAGE_KEYS } from "../common/constants"; @@ -151,18 +152,15 @@ API.interceptors.response.use( return handleTokenRefresh(originalRequest); } - console.log("❌ [API FAILED] Error", error); + // console output becomes a Sentry breadcrumb in release builds: keep it to development + if (__DEV__) console.log("❌ [API FAILED] Error", error); reportApiError(error); throw error; }, ); -const UUID_REGEX = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi; - -// Replace ids so the same endpoint is grouped into a single Sentry issue -const normalizeUrl = (url?: string) => - (url ?? "").split("?")[0].replace(UUID_REGEX, "{id}").replace(/^\//, ""); +export { isExpectedApiError, isNetworkError }; const reportApiError = (error: AxiosError) => { if (axios.isCancel(error)) { @@ -178,24 +176,20 @@ const reportApiError = (error: AxiosError) => { return; } - let title: string; - let level: Sentry.SeverityLevel; - - if (status) { - // The server responded with an error status - title = `API ${status} ${method} ${url}`; - level = status >= 500 ? "error" : "warning"; - } else if (error.request) { - // No response: timeout or no connectivity - const isTimeout = error.code === "ECONNABORTED" || error.code === "ETIMEDOUT"; - title = `API ${isTimeout ? "timeout" : "network error"} ${method} ${url}`; - level = "warning"; - } else { + if (isExpectedApiError(error)) { + return; + } + + if (!status) { // The request could not be created Sentry.captureException(error); return; } + // The server responded with an error status + const title = `API ${status} ${method} ${url}`; + const level: Sentry.SeverityLevel = status >= 500 ? "error" : "warning"; + const apiError = new Error(title); apiError.name = "ApiError"; diff --git a/mobile/services/api/add-attachment.api.ts b/mobile/services/api/add-attachment.api.ts index 619d49535..d7a1d865c 100644 --- a/mobile/services/api/add-attachment.api.ts +++ b/mobile/services/api/add-attachment.api.ts @@ -1,6 +1,7 @@ import { FileMetadata } from "../../hooks/useCamera"; import API from "../api"; -import axios from "axios"; +import { File, Paths } from "expo-file-system"; +import { FileSystemUploadType, uploadAsync } from "expo-file-system/legacy"; /** ======================================================================== ================= POST addAttachment ==================== @@ -102,17 +103,63 @@ export const addAttachmentMultipartAbort = async ({ ).then((res) => res.data); }; -// Upload S3 Chunk of bytes (Buffer (array of bytes) - not Base64 - still bytes but written differently) -export const uploadS3Chunk = async (url: string, chunk: any): Promise<{ ETag: string }> => { - return axios - .put(url, chunk, { - timeout: 100000, - headers: { - "Content-Type": "application/octet-stream", - }, - }) - .then((res) => { - console.log(res); - return { ETag: res.headers.etag }; +// Upload one part of a multipart upload straight from disk. The bytes are uploaded natively by +// FileSystem.uploadAsync, so the chunk never goes through JS as a base64 string (which needed +// ~3-4x the chunk size in JS memory and crashed low-end phones on video uploads). +export type UploadS3ChunkPayload = { + url: string; + filePath: string; + partIndex: number; + partSize: number; +}; + +export const uploadS3Chunk = async ({ + url, + filePath, + partIndex, + partSize, +}: UploadS3ChunkPayload): Promise<{ ETag: string }> => { + const source = new File(filePath); + const fileSize = source.size; + const start = partIndex * partSize; + const isWholeFile = start === 0 && fileSize <= partSize; + + // Single-part files are uploaded as-is. For multi-part, copy the byte range to a temp file + // (native buffer, no base64) and upload that. + let partFile: File | null = null; + if (!isWholeFile) { + const handle = source.open(); + try { + handle.offset = start; + const bytes = handle.readBytes(Math.min(partSize, fileSize - start)); + partFile = new File(Paths.cache, `upload-part-${Date.now()}-${partIndex}`); + partFile.create({ overwrite: true }); + partFile.write(bytes); + } finally { + handle.close(); + } + } + + try { + const response = await uploadAsync(url, partFile ? partFile.uri : source.uri, { + httpMethod: "PUT", + uploadType: FileSystemUploadType.BINARY_CONTENT, + headers: { "Content-Type": "application/octet-stream" }, }); + + if (response.status < 200 || response.status >= 300) { + throw new Error(`S3 part upload failed with status ${response.status}`); + } + + const etagKey = Object.keys(response.headers).find((key) => key.toLowerCase() === "etag"); + const ETag = etagKey ? response.headers[etagKey] : undefined; + if (!ETag) { + throw new Error("S3 part upload response has no ETag header"); + } + return { ETag }; + } finally { + if (partFile?.exists) { + partFile.delete(); + } + } }; diff --git a/mobile/services/api/incident-report/incident-report.api.ts b/mobile/services/api/incident-report/incident-report.api.ts new file mode 100644 index 000000000..0ca7126ef --- /dev/null +++ b/mobile/services/api/incident-report/incident-report.api.ts @@ -0,0 +1,233 @@ +import API from "../../api"; +import { ApiFormAnswer } from "../../interfaces/answer.type"; + +export enum IncidentReportLocationType { + NotRelatedToAPollingStation = "NotRelatedToAPollingStation", + OtherPollingStation = "OtherPollingStation", + PollingStation = "PollingStation", +} + +export type IncidentReportAttachmentAPIResponse = { + submissionId: string; + questionId: string; + fileName: string; + mimeType: string; + presignedUrl: string; + urlValidityInSeconds: number; + uploadedFileName?: string; + filePath?: string; + timeSubmitted?: string; +}; + +export type IncidentReportNoteAPIResponse = { + // missing from servers that predate it + id?: string; + submissionId: string; + questionId: string; + text: string; + timeSubmitted: string; +}; + +export type IncidentReportAPIResponse = { + id: string; + formId: string; + locationType: IncidentReportLocationType; + pollingStationId?: string | null; + locationDescription?: string | null; + answers: ApiFormAnswer[]; + notes: IncidentReportNoteAPIResponse[]; + attachments: IncidentReportAttachmentAPIResponse[]; + timestamp?: string; + isCompleted: boolean; + isNotSynched?: boolean; +}; + +/** ======================================================================== + ================= GET my incident reports ==================== + ======================================================================== + @description Retrieves the incident reports of the current observer for an election round +*/ +export const getMyIncidentReports = ( + electionRoundId: string, +): Promise => { + return API.get<{ incidentReports: IncidentReportAPIResponse[] }>( + `election-rounds/${electionRoundId}/incident-reports:my`, + ).then((res) => res.data.incidentReports); +}; + +/** ======================================================================== + ================= POST incidentReport ==================== + ======================================================================== + @description Upsert an Incident Report (a submission of an IncidentReporting form) +*/ +export type UpsertIncidentReportAPIPayload = { + id: string; + electionRoundId: string; + formId: string; + locationType: IncidentReportLocationType; + pollingStationId?: string; + locationDescription?: string; + answers: ApiFormAnswer[]; + isCompleted?: boolean; + lastUpdatedAt: string; +}; + +export const upsertIncidentReport = ({ + electionRoundId, + ...payload +}: UpsertIncidentReportAPIPayload): Promise => { + return API.post(`election-rounds/${electionRoundId}/incident-reports`, payload).then( + (res) => res.data, + ); +}; + +/** ======================================================================== + ================= Incident report attachments (multipart upload) ====== + ======================================================================== +*/ +export type AddAttachmentIncidentReportStartAPIPayload = { + id: string; + electionRoundId: string; + incidentReportId: string; + formId: string; + questionId: string; + fileName: string; + contentType: string; + numberOfUploadParts: number; +}; + +export type AddAttachmentIncidentReportCompleteAPIPayload = { + uploadId: string; + id: string; + etags: Record; + incidentReportId: string; + electionRoundId: string; +}; + +export type AddAttachmentIncidentReportAbortAPIPayload = { + uploadId: string; + id: string; + incidentReportId: string; + electionRoundId: string; +}; + +export const addAttachmentIncidentReportMultipartStart = ({ + electionRoundId, + incidentReportId, + ...payload +}: AddAttachmentIncidentReportStartAPIPayload): Promise<{ + uploadId: string; + uploadUrls: Record; +}> => { + return API.post( + `election-rounds/${electionRoundId}/incident-reports/${incidentReportId}/attachments:init`, + payload, + ).then((res) => res.data); +}; + +export const addAttachmentIncidentReportMultipartComplete = ({ + uploadId, + id, + etags, + incidentReportId, + electionRoundId, +}: AddAttachmentIncidentReportCompleteAPIPayload): Promise => { + return API.post( + `election-rounds/${electionRoundId}/incident-reports/${incidentReportId}/attachments/${id}:complete`, + { uploadId, etags }, + ).then((res) => res.data); +}; + +export const addAttachmentIncidentReportMultipartAbort = ({ + uploadId, + id, + incidentReportId, + electionRoundId, +}: AddAttachmentIncidentReportAbortAPIPayload): Promise => { + return API.post( + `election-rounds/${electionRoundId}/incident-reports/${incidentReportId}/attachments/${id}:abort`, + { uploadId }, + ).then((res) => res.data); +}; + +/** ======================================================================== + ================= POST incidentReportNote ==================== + ======================================================================== + @description Upsert a note on a question of an Incident Report (the report has to exist) +*/ +export type UpsertIncidentReportNoteAPIPayload = { + id: string; + electionRoundId: string; + incidentReportId: string; + formId: string; + questionId: string; + text: string; +}; + +export const upsertIncidentReportNote = ({ + electionRoundId, + incidentReportId, + ...payload +}: UpsertIncidentReportNoteAPIPayload): Promise => { + return API.post( + `election-rounds/${electionRoundId}/incident-reports/${incidentReportId}/notes`, + payload, + ).then((res) => res.data); +}; + +export type DeleteIncidentReportNoteAPIPayload = { + id: string; + electionRoundId: string; + incidentReportId: string; +}; + +export const deleteIncidentReportNote = ({ + id, + electionRoundId, + incidentReportId, +}: DeleteIncidentReportNoteAPIPayload): Promise => { + return API.delete( + `election-rounds/${electionRoundId}/incident-reports/${incidentReportId}/notes/${id}`, + ).then((res) => res.data); +}; + +/** ======================================================================== + ================= GET / DELETE incident report attachments ============ + ======================================================================== + @description Unlike the attachments in incident-reports:my, these have their ids (needed to delete them) +*/ +export type IncidentReportAttachmentWithIdAPIResponse = { + id: string; + incidentReportId: string; + formId: string; + questionId: string; + fileName: string; + mimeType: string; + presignedUrl: string; + urlValidityInSeconds: number; +}; + +export const getIncidentReportAttachments = ( + electionRoundId: string, + incidentReportId: string, + formId: string, +): Promise => { + return API.get<{ attachments: IncidentReportAttachmentWithIdAPIResponse[] }>( + `election-rounds/${electionRoundId}/incident-reports/${incidentReportId}/attachments`, + { params: { formId } }, + ).then((res) => res.data.attachments); +}; + +export const deleteIncidentReportAttachment = ({ + id, + electionRoundId, + incidentReportId, +}: { + id: string; + electionRoundId: string; + incidentReportId: string; +}): Promise => { + return API.delete( + `election-rounds/${electionRoundId}/incident-reports/${incidentReportId}/attachments/${id}`, + ).then((res) => res.data); +}; diff --git a/mobile/services/api/quick-report/get-quick-reports.api.ts b/mobile/services/api/quick-report/get-quick-reports.api.ts index 42ab1d936..39b62d9b6 100644 --- a/mobile/services/api/quick-report/get-quick-reports.api.ts +++ b/mobile/services/api/quick-report/get-quick-reports.api.ts @@ -28,6 +28,7 @@ export type QuickReportsAPIResponse = { pollingStationId?: string | null; pollingStationDetails?: string; attachments: Array; + timestamp?: string; }; export const getQuickReports = ( diff --git a/mobile/services/definitions.api.ts b/mobile/services/definitions.api.ts index 4b8fe9783..77666d696 100644 --- a/mobile/services/definitions.api.ts +++ b/mobile/services/definitions.api.ts @@ -206,6 +206,20 @@ export const getPollingStationInformation = ( @returns {PollingStationInformationAPIResponse} */ +export enum FormType { + Opening = "Opening", + Voting = "Voting", + ClosingAndCounting = "ClosingAndCounting", + PSI = "PSI", + IncidentReporting = "IncidentReporting", + CitizenReporting = "CitizenReporting", + Other = "Other", +} + +// incident reporting forms are filled in from the report tab, not from the observation forms list +export const isIncidentReportingForm = (form: Pick) => + form.formType === FormType.IncidentReporting; + export type FormAPIModel = { id: string; formType: string; // "ClosingAndCounting", diff --git a/mobile/services/form.parser.ts b/mobile/services/form.parser.ts index da5933ea5..c3484df78 100644 --- a/mobile/services/form.parser.ts +++ b/mobile/services/form.parser.ts @@ -4,6 +4,13 @@ import { FormListItem } from "../components/SingleSubmissionFormList"; import { MultiSubmissionFormListItem } from "../components/MultiSubmissionFormList"; import { FormSubmissionDetails } from "../components/FormSubmissionListItem"; import { arrayToKeyObject, groupArrayByKey } from "../helpers/misc"; +import { + fromDayIso, + fromTimeOfDay, + toDateTimeIso, + toDayIso, + toTimeOfDay, +} from "../common/utils/date-answers"; import { FormAPIModel, FormSubmission, FormSubmissionsApiResponse } from "./definitions.api"; import { ApiFormAnswer, @@ -21,10 +28,15 @@ import { } from "./interfaces/question.type"; const mapDisplayConditionToMath = ( - operand1: string, - operand2: string, + answer: string | number, + target: string, condition: DisplayLogicCondition, ) => { + // numbers and ratings compare as numbers ("10" > "9"); text compares as text + const numeric = typeof answer === "number"; + const operand1 = answer; + const operand2 = numeric ? Number(target) : target; + if (numeric && Number.isNaN(operand2)) return false; switch (condition) { case "Equals": return operand1 === operand2; @@ -43,6 +55,73 @@ const mapDisplayConditionToMath = ( } }; +// the values date, time and date-time questions send and read back (see date-answers) +export { fromDayIso, fromTimeOfDay, toDayIso, toTimeOfDay } from "../common/utils/date-answers"; + +/** + * Whether a select parent's picked options meet a display rule. The rule lists the options in + * `optionIds`: "AnyOf" needs one of them picked, "All" every one of them. + * A condition this version doesn't know shows the question, so its answer is never discarded. + */ +const matchesSelectCondition = ( + picked: string[], + logic: { condition: DisplayLogicCondition; value?: string | null; optionIds?: string[] | null }, +) => { + const targets = logic.optionIds ?? []; + switch (logic.condition) { + case "AnyOf": + return picked.some((id) => targets.includes(id)); + case "All": + return targets.length > 0 && targets.every((id) => picked.includes(id)); + default: + // older rules name a single option in value: shown when that option is picked + return !!logic.value && picked.includes(logic.value); + } +}; + +/** What an answer says, for comparing two answers (ignores stamps and display-only fields). */ +const answerValueKey = (answer: ApiFormAnswer): string => { + switch (answer.$answerType) { + case "textAnswer": + return `text:${answer.text}`; + case "numberAnswer": + case "ratingAnswer": + return `${answer.$answerType}:${answer.value}`; + case "dateAnswer": + return `date:${answer.date ? toDayIso(fromDayIso(answer.date)) : ""}`; + case "timeAnswer": + return `time:${answer.time?.slice(0, 5)}`; + case "dateTimeAnswer": + // to the minute, like what is sent: an older answer with seconds is not a change + return `dateTime:${toDateTimeIso(new Date(answer.dateTime))}`; + case "singleSelectAnswer": + return `single:${answer.selection?.optionId}:${answer.selection?.text ?? ""}`; + case "multiSelectAnswer": + return `multi:${(answer.selection ?? []) + .map((option) => `${option.optionId}:${option.text ?? ""}`) + .sort() + .join("|")}`; + default: + return JSON.stringify(answer); + } +}; + +/** + * Stamps an answer with when it was first given and last changed. An unchanged answer keeps its + * stamps, so re-saving a form doesn't move them; a changed one keeps its createdAt. + */ +export const withAnswerTimestamps = ( + next: ApiFormAnswer | undefined, + previous: ApiFormAnswer | undefined, + now: string = new Date().toISOString(), +): ApiFormAnswer | undefined => { + if (!next) return undefined; + if (previous && answerValueKey(previous) === answerValueKey(next)) { + return { ...next, createdAt: previous.createdAt, lastUpdatedAt: previous.lastUpdatedAt }; + } + return { ...next, createdAt: previous?.createdAt ?? now, lastUpdatedAt: now }; +}; + export const shouldDisplayQuestion = ( q: ApiFormQuestion, answers: Record | undefined, @@ -54,26 +133,31 @@ export const shouldDisplayQuestion = ( const condition: DisplayLogicCondition = q.displayLogic.condition; const parentAnswerType = answers?.[q.displayLogic?.parentQuestionId]?.$answerType; const parentAnswer = answers?.[q.displayLogic?.parentQuestionId]; - const value = q.displayLogic?.value; + const value = q.displayLogic?.value ?? ""; + const optionIds = q.displayLogic?.optionIds ?? []; let shouldDisplay = false; - if (!condition || !parentAnswerType || !value) { + if (!condition || !parentAnswerType || (!value && optionIds.length === 0)) { return shouldDisplay; } switch (parentAnswerType) { case "multiSelectAnswer": - shouldDisplay = !!(parentAnswer as MultiSelectAnswer).selection.find( - (option) => option.optionId === value, + shouldDisplay = matchesSelectCondition( + (parentAnswer as MultiSelectAnswer).selection.map((option) => option.optionId), + q.displayLogic, ); break; case "singleSelectAnswer": - shouldDisplay = (parentAnswer as SingleSelectAnswer).selection.optionId === value; + shouldDisplay = matchesSelectCondition( + [(parentAnswer as SingleSelectAnswer).selection.optionId], + q.displayLogic, + ); break; case "numberAnswer": shouldDisplay = mapDisplayConditionToMath( - (parentAnswer as NumberAnswer).value.toString(), + (parentAnswer as NumberAnswer).value, value, condition, ); @@ -87,7 +171,7 @@ export const shouldDisplayQuestion = ( break; case "ratingAnswer": shouldDisplay = mapDisplayConditionToMath( - (parentAnswer as RatingAnswer).value.toString(), + (parentAnswer as RatingAnswer).value, value, condition, ); @@ -147,6 +231,8 @@ export const mapFormSubmissionDataToAPIFormSubmissionAnswer = ( answer: | string | number + // date, time and date-time questions + | Date | Record | Record, ): ApiFormAnswer | undefined => { @@ -175,7 +261,19 @@ export const mapFormSubmissionDataToAPIFormSubmissionAnswer = ( return { $answerType: "dateAnswer", questionId, - date: new Date(answer as string).toISOString(), + date: toDayIso(new Date(answer as string | Date)), + } as ApiFormAnswer; + case "timeAnswer": + return { + $answerType: "timeAnswer", + questionId, + time: toTimeOfDay(new Date(answer as string | Date)), + } as ApiFormAnswer; + case "dateTimeAnswer": + return { + $answerType: "dateTimeAnswer", + questionId, + dateTime: toDateTimeIso(new Date(answer as string | Date)), } as ApiFormAnswer; case "singleSelectAnswer": return { @@ -217,7 +315,11 @@ export const setFormDefaultValues = (questionId: string, answer?: ApiFormAnswer) case "ratingAnswer": return { [questionId]: answer?.value?.toString() ?? "" }; case "dateAnswer": - return { [questionId]: answer.date ? new Date(answer.date) : "" }; + return { [questionId]: answer.date ? fromDayIso(answer.date) : "" }; + case "timeAnswer": + return { [questionId]: answer.time ? fromTimeOfDay(answer.time) : "" }; + case "dateTimeAnswer": + return { [questionId]: answer.dateTime ? new Date(answer.dateTime) : "" }; case "singleSelectAnswer": return { [questionId]: { diff --git a/mobile/services/interfaces/answer.type.ts b/mobile/services/interfaces/answer.type.ts index 298783b44..abc42adb6 100644 --- a/mobile/services/interfaces/answer.type.ts +++ b/mobile/services/interfaces/answer.type.ts @@ -4,6 +4,8 @@ export type ApiFormAnswerType = | "textAnswer" | "numberAnswer" | "dateAnswer" + | "timeAnswer" + | "dateTimeAnswer" | "singleSelectAnswer" | "multiSelectAnswer" | "ratingAnswer"; @@ -13,34 +15,54 @@ export type ApiFormAnswer = | NumberAnswer | RatingAnswer | DateAnswer + | TimeAnswer + | DateTimeAnswer | SingleSelectAnswer | MultiSelectAnswer; -export type TextAnswer = { +// When the answer was first given and last changed on the device (ISO). Optional: older answers have neither. +type AnswerTimestamps = { + createdAt?: string; + lastUpdatedAt?: string; +}; + +export type TextAnswer = AnswerTimestamps & { questionId: string; $answerType: "textAnswer"; text: string; }; -export type NumberAnswer = { +export type NumberAnswer = AnswerTimestamps & { questionId: string; $answerType: "numberAnswer"; value: number; }; -export type RatingAnswer = { +export type RatingAnswer = AnswerTimestamps & { questionId: string; $answerType: "ratingAnswer"; value: number; }; -export type DateAnswer = { +export type DateAnswer = AnswerTimestamps & { questionId: string; $answerType: "dateAnswer"; - date: string; // ISO String + date: string; // the picked day as "YYYY-MM-DDT00:00:00Z"; only the date part counts +}; + +export type TimeAnswer = AnswerTimestamps & { + questionId: string; + $answerType: "timeAnswer"; + time: string; // "HH:mm:ss", a wall-clock time without a date or time zone +}; + +export type DateTimeAnswer = AnswerTimestamps & { + questionId: string; + $answerType: "dateTimeAnswer"; + dateTime: string; // ISO String }; -export type SingleSelectAnswer = { +export type SingleSelectAnswer = AnswerTimestamps & { questionId: string; $answerType: "singleSelectAnswer"; selection: { @@ -50,7 +72,7 @@ export type SingleSelectAnswer = { }; }; -export type MultiSelectAnswer = { +export type MultiSelectAnswer = AnswerTimestamps & { questionId: string; $answerType: "multiSelectAnswer"; selection: { @@ -64,6 +86,8 @@ export const FormQuestionAnswerTypeMapping: Record { id: MUTATION_SCOPE_DO_NOT_HYDRATE, }, mutationKey: AttachmentsKeys.addS3ChunkMutation(), - mutationFn: (payload: { url: string; chunk: any }): Promise<{ ETag: string }> => - uploadS3Chunk(payload.url, payload.chunk), + mutationFn: (payload: UploadS3ChunkPayload) => uploadS3Chunk(payload), retry: 3, }); }; diff --git a/mobile/services/mutations/incident-report/add-attachment-incident-report.mutation.ts b/mobile/services/mutations/incident-report/add-attachment-incident-report.mutation.ts new file mode 100644 index 000000000..026060280 --- /dev/null +++ b/mobile/services/mutations/incident-report/add-attachment-incident-report.mutation.ts @@ -0,0 +1,52 @@ +import { useMutation } from "@tanstack/react-query"; +import * as Sentry from "@sentry/react-native"; +import { MUTATION_SCOPE_DO_NOT_HYDRATE } from "../../../common/constants"; +import { + AddAttachmentIncidentReportAbortAPIPayload, + AddAttachmentIncidentReportCompleteAPIPayload, + AddAttachmentIncidentReportStartAPIPayload, + addAttachmentIncidentReportMultipartAbort, + addAttachmentIncidentReportMultipartComplete, + addAttachmentIncidentReportMultipartStart, +} from "../../api/incident-report/incident-report.api"; +import { IncidentReportKeys } from "../../queries/incident-reports.query"; + +// Multipart Upload - Start +export const useUploadAttachmentIncidentReportMutation = () => { + return useMutation({ + mutationKey: IncidentReportKeys.addAttachment(), + scope: { + id: MUTATION_SCOPE_DO_NOT_HYDRATE, + }, + mutationFn: (payload: AddAttachmentIncidentReportStartAPIPayload) => + addAttachmentIncidentReportMultipartStart(payload), + onError: (err, variables) => { + Sentry.captureException(err, { data: { variables } }); + }, + retry: 3, + }); +}; + +export const useUploadAttachmentIncidentReportCompleteMutation = () => { + return useMutation({ + mutationKey: IncidentReportKeys.addAttachmentComplete(), + scope: { + id: MUTATION_SCOPE_DO_NOT_HYDRATE, + }, + mutationFn: (payload: AddAttachmentIncidentReportCompleteAPIPayload) => + addAttachmentIncidentReportMultipartComplete(payload), + retry: 3, + }); +}; + +export const useUploadAttachmentIncidentReportAbortMutation = () => { + return useMutation({ + mutationKey: IncidentReportKeys.addAttachmentAbort(), + scope: { + id: MUTATION_SCOPE_DO_NOT_HYDRATE, + }, + mutationFn: (payload: AddAttachmentIncidentReportAbortAPIPayload) => + addAttachmentIncidentReportMultipartAbort(payload), + retry: 3, + }); +}; diff --git a/mobile/services/mutations/incident-report/add-incident-report.mutation.ts b/mobile/services/mutations/incident-report/add-incident-report.mutation.ts new file mode 100644 index 000000000..71ee5b6b5 --- /dev/null +++ b/mobile/services/mutations/incident-report/add-incident-report.mutation.ts @@ -0,0 +1,125 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { + IncidentReportAPIResponse, + IncidentReportAttachmentAPIResponse, + IncidentReportNoteAPIResponse, + UpsertIncidentReportAPIPayload, + UpsertIncidentReportNoteAPIPayload, + DeleteIncidentReportNoteAPIPayload, + deleteIncidentReportAttachment, +} from "../../api/incident-report/incident-report.api"; +import { IncidentReportKeys } from "../../queries/incident-reports.query"; +import { retryWhileNoConnection } from "../../api-errors"; + +const retryDelay = (attempt: number) => Math.min(1000 * 2 ** attempt, 30 * 1000); + +// attachments and notes are only used for the optimistic update, they are sent separately +export type AddIncidentReportMutationPayload = UpsertIncidentReportAPIPayload & { + attachments: IncidentReportAttachmentAPIResponse[]; + notes: IncidentReportNoteAPIResponse[]; +}; + +// the mutationFns are set through setMutationDefaults (PersistQueryContext) so they can resume offline; +// the report and its notes share a scope, so the notes are only sent after the report was created +export const useAddIncidentReport = (incidentReportId: string) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: IncidentReportKeys.add(), + scope: { id: IncidentReportKeys.mutationScope(incidentReportId) }, + retry: retryWhileNoConnection, + retryDelay, + onMutate: async ({ + attachments, + notes, + electionRoundId, + ...payload + }: AddIncidentReportMutationPayload) => { + const queryKey = IncidentReportKeys.byElectionRound(electionRoundId); + // so an outgoing refetch does not overwrite the optimistic update + await queryClient.cancelQueries({ queryKey }); + + const previousReports = queryClient.getQueryData(queryKey); + + queryClient.setQueryData(queryKey, (old = []) => [ + ...old.filter((report) => report.id !== payload.id), + { + id: payload.id, + formId: payload.formId, + locationType: payload.locationType, + pollingStationId: payload.pollingStationId, + locationDescription: payload.locationDescription, + answers: payload.answers, + isCompleted: payload.isCompleted ?? false, + timestamp: payload.lastUpdatedAt, + notes, + attachments, + isNotSynched: true, + }, + ]); + + return { previousReports }; + }, + onError: (_err, variables, context) => { + const queryKey = IncidentReportKeys.byElectionRound(variables.electionRoundId); + queryClient.setQueryData(queryKey, context?.previousReports); + }, + onSettled: (_data, _err, variables) => { + return queryClient.invalidateQueries({ + queryKey: IncidentReportKeys.byElectionRound(variables.electionRoundId), + }); + }, + }); +}; + +export const useAddIncidentReportNote = (incidentReportId: string) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: IncidentReportKeys.addNote(), + scope: { id: IncidentReportKeys.mutationScope(incidentReportId) }, + retry: retryWhileNoConnection, + retryDelay, + onSettled: (_data, _err, variables: UpsertIncidentReportNoteAPIPayload) => { + return queryClient.invalidateQueries({ + queryKey: IncidentReportKeys.byElectionRound(variables.electionRoundId), + }); + }, + }); +}; + +export const useDeleteIncidentReportNote = (incidentReportId: string) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: IncidentReportKeys.deleteNote(), + scope: { id: IncidentReportKeys.mutationScope(incidentReportId) }, + retry: retryWhileNoConnection, + retryDelay, + onSettled: (_data, _err, variables: DeleteIncidentReportNoteAPIPayload) => { + return queryClient.invalidateQueries({ + queryKey: IncidentReportKeys.byElectionRound(variables.electionRoundId), + }); + }, + }); +}; + +// online only: deleting a file needs the server (adding one does too) +export const useDeleteIncidentReportAttachment = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: deleteIncidentReportAttachment, + onSettled: (_data, _err, variables) => { + queryClient.invalidateQueries({ + queryKey: IncidentReportKeys.byElectionRound(variables.electionRoundId), + }); + queryClient.invalidateQueries({ + queryKey: IncidentReportKeys.attachments( + variables.electionRoundId, + variables.incidentReportId, + ), + }); + }, + }); +}; diff --git a/mobile/services/queries.service.ts b/mobile/services/queries.service.ts index b2669b2fc..fb7dae47e 100644 --- a/mobile/services/queries.service.ts +++ b/mobile/services/queries.service.ts @@ -12,6 +12,7 @@ import * as DB from "../database/DAO/PollingStationsNomenclatorDAO"; import { PollingStationNomenclatorNodeVM } from "../common/models/polling-station.model"; import AsyncStorage from "@react-native-async-storage/async-storage"; import * as Sentry from "@sentry/react-native"; +import { isExpectedApiError } from "./api"; import { exists } from "i18next"; export const electionRoundsKeys = { @@ -174,10 +175,12 @@ export const usePollingStationsNomenclatorQuery = (electionRoundId: string | und } } catch (err) { console.warn("usePollingStationsNomenclatorQuery", err); - Sentry.captureMessage( - `Failed to save nomenclator. Exists: ${exists}, LocalVersionKey: ${localVersionKey}, ServerVersionKey: ${serverVersionKey}`, - ); - Sentry.captureException(err); + if (!isExpectedApiError(err)) { + Sentry.captureMessage( + `Failed to save nomenclator. Exists: ${exists}, LocalVersionKey: ${localVersionKey}, ServerVersionKey: ${serverVersionKey}`, + ); + Sentry.captureException(err); + } throw err; } } diff --git a/mobile/services/queries/citizen.query.ts b/mobile/services/queries/citizen.query.ts index 7565918e0..d647785dd 100644 --- a/mobile/services/queries/citizen.query.ts +++ b/mobile/services/queries/citizen.query.ts @@ -7,6 +7,7 @@ import { } from "../api/citizen/get-election-round-locations"; import * as CitizenLocationsDB from "../../database/DAO/CitizenLocationsDAO"; import * as Sentry from "@sentry/react-native"; +import { isExpectedApiError } from "../api"; import { CitizenLocationVM } from "../../common/models/citizen-locations.model"; import { ElectionRoundsAllFormsAPIResponse } from "../definitions.api"; import { useCallback } from "react"; @@ -96,10 +97,12 @@ export const useGetCitizenLocations = (electionRoundId: string) => { } } catch (err) { console.warn("useGetCitizenReportingForms", err); - Sentry.captureMessage( - `Failed to get citizen reporting forms. ElectionRoundId: ${electionRoundId}`, - ); - Sentry.captureException(err); + if (!isExpectedApiError(err)) { + Sentry.captureMessage( + `Failed to get citizen reporting forms. ElectionRoundId: ${electionRoundId}`, + ); + Sentry.captureException(err); + } throw err; } } diff --git a/mobile/services/queries/incident-reports.query.ts b/mobile/services/queries/incident-reports.query.ts new file mode 100644 index 000000000..88fa36a9a --- /dev/null +++ b/mobile/services/queries/incident-reports.query.ts @@ -0,0 +1,119 @@ +import { QueryClient, skipToken, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useCallback } from "react"; +import { + getIncidentReportAttachments, + getMyIncidentReports, + IncidentReportAPIResponse, + UpsertIncidentReportAPIPayload, +} from "../api/incident-report/incident-report.api"; +import { + ElectionRoundsAllFormsAPIResponse, + FormAPIModel, + isIncidentReportingForm, +} from "../definitions.api"; +import { AttachmentsKeys } from "./attachments.query"; +import { useElectionRoundAllForms } from "./forms.query"; + +export const IncidentReportKeys = { + all: ["incident-reports"] as const, + byElectionRound: (electionRoundId: string | undefined) => [ + ...IncidentReportKeys.all, + "electionRoundId", + electionRoundId, + ], + add: () => [...IncidentReportKeys.all, "add"] as const, + addNote: () => [...IncidentReportKeys.all, "addNote"] as const, + deleteNote: () => [...IncidentReportKeys.all, "deleteNote"] as const, + attachments: (electionRoundId: string | undefined, incidentReportId: string | undefined) => [ + ...IncidentReportKeys.all, + "attachments", + electionRoundId, + incidentReportId, + ], + // the report and its notes run one after the other (also when resumed after being offline): + // a note can only be added to a report that exists on the server + mutationScope: (incidentReportId: string) => `incident-report:${incidentReportId}`, + addAttachment: () => [...AttachmentsKeys.all, ...IncidentReportKeys.all, "addAttachment"], + addAttachmentComplete: () => [...IncidentReportKeys.all, "addAttachmentComplete"], + addAttachmentAbort: () => [...IncidentReportKeys.all, "addAttachmentAbort"], +}; + +/* + A sent report is locked as soon as "Send report" is pressed, even before the server has it: while + that save is still pending (queued offline, or behind the report's earlier saves) the server still + returns the report as a draft, so a refetch would make it editable again. Those reports keep what + was sent, as completed. +*/ +const withPendingSends = ( + queryClient: QueryClient, + reports: IncidentReportAPIResponse[], +): IncidentReportAPIResponse[] => { + const pendingSends = new Map( + queryClient + .getMutationCache() + .findAll({ mutationKey: IncidentReportKeys.add(), status: "pending" }) + .map((mutation) => mutation.state.variables as UpsertIncidentReportAPIPayload | undefined) + .filter((payload) => !!payload?.isCompleted) + .map((payload) => [payload!.id, payload!]), + ); + if (!pendingSends.size) return reports; + + return reports.map((report) => { + const sent = pendingSends.get(report.id); + return sent && !report.isCompleted + ? { ...report, answers: sent.answers, isCompleted: true, isNotSynched: true } + : report; + }); +}; + +export const useIncidentReports = ( + electionRoundId?: string, + select?: (data: IncidentReportAPIResponse[]) => TResult, +) => { + const queryClient = useQueryClient(); + + return useQuery({ + queryKey: IncidentReportKeys.byElectionRound(electionRoundId), + queryFn: electionRoundId + ? async () => withPendingSends(queryClient, await getMyIncidentReports(electionRoundId)) + : skipToken, + select, + }); +}; + +export const useIncidentReportById = ( + electionRoundId: string | undefined, + incidentReportId: string, +) => { + return useIncidentReports( + electionRoundId, + useCallback( + (data: IncidentReportAPIResponse[]) => + data.find((incidentReport) => incidentReport.id === incidentReportId), + [incidentReportId], + ), + ); +}; + +const selectIncidentReportingForms = (data: ElectionRoundsAllFormsAPIResponse): FormAPIModel[] => + data.forms.filter(isIncidentReportingForm); + +export const useIncidentReportingForms = (electionRoundId: string | undefined) => + useElectionRoundAllForms(electionRoundId, selectIncidentReportingForms); + +// the attachments of a report with their ids (incident-reports:my does not have them) +export const useIncidentReportAttachments = ( + electionRoundId: string | undefined, + incidentReportId: string | undefined, + formId: string | undefined, + enabled: boolean, +) => + useQuery({ + queryKey: IncidentReportKeys.attachments(electionRoundId, incidentReportId), + queryFn: + enabled && electionRoundId && incidentReportId && formId + ? () => getIncidentReportAttachments(electionRoundId, incidentReportId, formId) + : skipToken, + // continuing a draft needs the current files + refetchOnMount: "always", + }); diff --git a/mobile/tests/api-errors.test.ts b/mobile/tests/api-errors.test.ts new file mode 100644 index 000000000..bda6b1341 --- /dev/null +++ b/mobile/tests/api-errors.test.ts @@ -0,0 +1,87 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { AxiosError } from "axios"; +import { + isExpectedApiError, + isNetworkError, + retryWhileNoConnection, +} from "../services/api-errors.ts"; + +const ID = "7f1c2a3b-0000-4000-8000-000000000001"; + +const withStatus = (url: string, status: number) => + new AxiosError( + "failed", + "ERR_BAD_REQUEST", + { url, headers: {} } as any, + {}, + { + status, + data: {}, + statusText: "", + headers: {}, + config: { url, headers: {} } as any, + }, + ); + +const noResponse = (url: string, code = "ERR_NETWORK") => + new AxiosError("Network Error", code, { url, headers: {} } as any, {}); + +describe("isExpectedApiError (not reported to Sentry)", () => { + test("404 only from endpoints where it means there is nothing to fetch", () => { + for (const url of [ + `/election-rounds/${ID}/citizen-reporting-forms`, + `/election-rounds/${ID}/locations:version`, + `/election-rounds/${ID}/locations:fetchAll`, + `election-rounds/${ID}/polling-stations:version`, + `election-rounds/${ID}/polling-stations:fetchAll?x=1`, + `election-rounds/${ID}/polling-station-information-form`, + ]) { + assert.equal(isExpectedApiError(withStatus(url, 404)), true, url); + } + }); + + test("a 404 anywhere else is reported (e.g. an endpoint broken by a deploy)", () => { + for (const url of [ + `election-rounds/${ID}/incident-reports`, + `election-rounds/${ID}/incident-reports:my`, + `election-rounds/${ID}/form-submissions:my`, + `election-rounds/${ID}/quick-reports:my`, + ]) { + assert.equal(isExpectedApiError(withStatus(url, 404)), false, url); + } + }); + + test("other statuses are reported, requests without a response are not", () => { + const url = `election-rounds/${ID}/incident-reports`; + assert.equal(isExpectedApiError(withStatus(url, 500)), false); + assert.equal(isExpectedApiError(withStatus(url, 409)), false); + assert.equal(isExpectedApiError(noResponse(url)), true); + assert.equal(isExpectedApiError(noResponse(url, "ECONNABORTED")), true); + assert.equal(isExpectedApiError(new TypeError("Network request failed")), true); + assert.equal(isExpectedApiError(new Error("boom")), false); + }); +}); + +describe("isNetworkError", () => { + test("only requests that got no response", () => { + const url = `election-rounds/${ID}/incident-reports`; + assert.equal(isNetworkError(noResponse(url)), true); + assert.equal(isNetworkError(withStatus(url, 500)), false); + assert.equal(isNetworkError(new Error("x")), false); + }); +}); + +describe("retryWhileNoConnection", () => { + test("retries a request without a response up to 10 times", () => { + const error = noResponse("x"); + assert.equal(retryWhileNoConnection(0, error), true); + assert.equal(retryWhileNoConnection(9, error), true); + assert.equal(retryWhileNoConnection(10, error), false); + }); + + test("never retries an error the server answered", () => { + assert.equal(retryWhileNoConnection(0, withStatus("x", 409)), false); + assert.equal(retryWhileNoConnection(0, withStatus("x", 500)), false); + }); +}); diff --git a/mobile/tests/date-answers.test.ts b/mobile/tests/date-answers.test.ts new file mode 100644 index 000000000..2ab247058 --- /dev/null +++ b/mobile/tests/date-answers.test.ts @@ -0,0 +1,142 @@ +/* + What date, time and date-time questions send and read back, in the observer's own time zone: + - date-time: the picked moment as a UTC instant, to the minute + - date: the day shown, with no time zone shift + - time: the time shown, with no time zone shift + Node applies a change of process.env.TZ right away, so each case runs in several time zones. +*/ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { + dayOfIso, + fromDayIso, + fromTimeOfDay, + toDateTimeIso, + toDayIso, + toTimeOfDay, +} from "../common/utils/date-answers.ts"; + +const TIME_ZONES = [ + "UTC", + "Europe/Bucharest", // UTC+2/+3 + "America/New_York", // UTC-5/-4 + "Asia/Kolkata", // UTC+5:30 + "Asia/Tokyo", // UTC+9 + "Pacific/Kiritimati", // UTC+14 + "Pacific/Pago_Pago", // UTC-11 +]; + +const inEachTimeZone = (name: string, fn: () => void) => { + for (const timeZone of TIME_ZONES) { + test(`${name} [${timeZone}]`, () => { + const previous = process.env.TZ; + process.env.TZ = timeZone; + try { + fn(); + } finally { + process.env.TZ = previous; + } + }); + } +}; + +const ymd = (d: Date | null) => + d && + `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + +// the pickers return a local Date: the observer picks 2 May 2024 at 00:30, 13:05 or 23:45 on screen +// (with seconds left over, as the Android picker does) +const picked = () => + [ + [0, 30], + [13, 5], + [23, 45], + ].map(([h, m]) => new Date(2024, 4, 2, h, m, 17, 250)); + +describe("date questions send the day shown", () => { + inEachTimeZone("sent value", () => { + for (const date of picked()) { + const sent = toDayIso(date); + assert.equal(sent, "2024-05-02T00:00:00Z"); + // the API (a DateTime) reads it as midnight UTC: still 2 May + assert.equal(new Date(sent).toISOString().slice(0, 10), "2024-05-02"); + // read back: 2 May on screen + assert.equal(ymd(fromDayIso(sent)), "2024-05-02"); + } + }); +}); + +describe("time questions send the time shown", () => { + inEachTimeZone("sent value, without seconds", () => { + assert.deepEqual(picked().map(toTimeOfDay), ["00:30:00", "13:05:00", "23:45:00"]); + for (const date of picked()) { + const back = fromTimeOfDay(toTimeOfDay(date)); + assert.equal( + `${back.getHours()}:${back.getMinutes()}`, + `${date.getHours()}:${date.getMinutes()}`, + ); + } + }); +}); + +describe("date-time questions send the picked moment in UTC, to the minute", () => { + inEachTimeZone("sent value", () => { + for (const date of picked()) { + const sent = toDateTimeIso(date); + assert.match(sent, /:00\.000Z$/); + const minute = new Date(date); + minute.setSeconds(0, 0); + assert.equal(new Date(sent).getTime(), minute.getTime()); + // shown back in the observer's time zone as what they picked + assert.equal(new Date(sent).getHours(), date.getHours()); + assert.equal(date.getSeconds(), 17); // the picked Date itself is not changed + } + }); + + test("2 May 01:30 in Bucharest is sent as 1 May 22:30 UTC", () => { + const previous = process.env.TZ; + process.env.TZ = "Europe/Bucharest"; + try { + assert.equal(toDateTimeIso(new Date(2024, 4, 2, 1, 30, 42, 123)), "2024-05-01T22:30:00.000Z"); + } finally { + process.env.TZ = previous; + } + }); +}); + +describe("date answers saved by older versions", () => { + // the old date picker stored the picked local moment as a full UTC instant: the day shown must be + // the one that was picked, not the first 10 characters (2 May 01:30 in UTC+3 is "2024-05-01T22:30Z") + inEachTimeZone("show the day that was picked", () => { + for (const date of picked()) { + const stored = new Date( + date.getFullYear(), + date.getMonth(), + date.getDate(), + date.getHours(), + date.getMinutes(), + ).toISOString(); + // a value that happens to be exactly midnight UTC is read as a new answer: the same day when + // the picker's time zone is UTC or east of it + const expected = /T00:00:00\.000Z$/.test(stored) ? stored.slice(0, 10) : "2024-05-02"; + assert.equal(ymd(dayOfIso(stored)), expected, stored); + } + }); + + inEachTimeZone("new values keep their day", () => { + for (const value of [ + "2024-05-02T00:00:00Z", + "2024-05-02T00:00:00.000Z", + "2024-05-02T00:00:00", + "2024-05-02T00:00:00+00:00", + "2024-05-02", + ]) { + assert.equal(ymd(dayOfIso(value)), "2024-05-02", value); + } + }); + + test("not a date", () => { + assert.equal(dayOfIso("not a date"), null); + assert.equal(dayOfIso(""), null); + }); +}); diff --git a/mobile/tests/incident-report-retry.test.ts b/mobile/tests/incident-report-retry.test.ts new file mode 100644 index 000000000..3b813e80a --- /dev/null +++ b/mobile/tests/incident-report-retry.test.ts @@ -0,0 +1,109 @@ +/* + How an incident report save behaves with the real retry rule in TanStack Query: it gives up after + 10 retries when there is a connection but no answer, so the saves queued behind it in the report's + scope (notes) still run, and it waits (without using up retries) while offline. +*/ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { AxiosError } from "axios"; +import { MutationObserver, QueryClient, onlineManager } from "@tanstack/react-query"; +import { retryWhileNoConnection } from "../services/api-errors.ts"; + +const networkError = () => new AxiosError("Network Error", "ERR_NETWORK", {} as any, {}); +const conflict = () => + new AxiosError( + "Conflict", + "ERR_BAD_REQUEST", + {} as any, + {}, + { + status: 409, + data: {}, + headers: {}, + statusText: "", + config: {} as any, + }, + ); + +describe("incident report saves", () => { + test("with no answer, a send stops after 11 attempts instead of retrying forever", async () => { + const client = new QueryClient(); + let attempts = 0; + const send = new MutationObserver(client, { + mutationFn: async () => { + attempts++; + throw networkError(); + }, + retry: retryWhileNoConnection, + retryDelay: 0, + scope: { id: "incident-report:1" }, + }); + await send.mutate().catch(() => {}); + assert.equal(attempts, 11); // the first try and 10 retries + assert.equal(send.getCurrentResult().status, "error"); + }); + + test("a note queued behind a send that failed for good still runs", async () => { + const client = new QueryClient(); + let noteRan = false; + const send = new MutationObserver(client, { + mutationFn: async () => { + throw networkError(); + }, + retry: retryWhileNoConnection, + retryDelay: 0, + scope: { id: "incident-report:2" }, + }); + const note = new MutationObserver(client, { + mutationFn: async () => { + noteRan = true; + }, + scope: { id: "incident-report:2" }, + }); + await Promise.all([send.mutate().catch(() => {}), note.mutate()]); + assert.equal(noteRan, true); + }); + + test("an error the server answered (e.g. a locked report) is not retried", async () => { + const client = new QueryClient(); + let attempts = 0; + const save = new MutationObserver(client, { + mutationFn: async () => { + attempts++; + throw conflict(); + }, + retry: retryWhileNoConnection, + retryDelay: 0, + }); + await save.mutate().catch(() => {}); + assert.equal(attempts, 1); + }); + + test("offline, a send waits without using up retries and goes through once back online", async () => { + const client = new QueryClient(); + client.mount(); + let attempts = 0; + onlineManager.setOnline(false); + try { + const send = new MutationObserver(client, { + mutationFn: async () => { + attempts++; + return "sent"; + }, + retry: retryWhileNoConnection, + retryDelay: 0, + }); + const result = send.mutate(); + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.equal(attempts, 0); + assert.equal(send.getCurrentResult().isPaused, true); + + onlineManager.setOnline(true); + assert.equal(await result, "sent"); + assert.equal(attempts, 1); + } finally { + onlineManager.setOnline(true); + client.unmount(); + } + }); +}); diff --git a/mobile/tests/persisted-client.test.ts b/mobile/tests/persisted-client.test.ts new file mode 100644 index 000000000..ccd78ced2 --- /dev/null +++ b/mobile/tests/persisted-client.test.ts @@ -0,0 +1,112 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { AxiosError } from "axios"; +import { + QueryClient, + defaultShouldDehydrateQuery, + dehydrate, + hydrate, + type Query, +} from "@tanstack/react-query"; +import { withoutRequestErrors } from "../contexts/persist-query/persisted-client.ts"; + +const networkError = () => + new AxiosError( + "Network Error", + "ERR_NETWORK", + { + url: "election-rounds/1/incident-reports:my", + headers: { Authorization: "Bearer SECRET-TOKEN" }, + } as any, + {}, + ); + +// the same rule as PersistQueryContext's shouldDehydrateQuery +const shouldDehydrateQuery = (query: Query) => + defaultShouldDehydrateQuery(query) || + (query.state.status === "error" && query.state.data !== undefined); + +describe("withoutRequestErrors (what is written to storage)", () => { + test("a query whose refetch failed is kept with its data, as a success, without the error", async () => { + const client = new QueryClient(); + const key = ["incident-reports", "1"]; + await client.fetchQuery({ queryKey: key, queryFn: async () => [{ id: "a" }] }); + await client + .fetchQuery({ + queryKey: key, + queryFn: async () => { + throw networkError(); + }, + retry: false, + staleTime: 0, + }) + .catch(() => {}); + assert.equal(client.getQueryState(key)?.status, "error"); + + const persisted = { + timestamp: Date.now(), + buster: "", + clientState: dehydrate(client, { shouldDehydrateQuery }), + }; + + // without it, the AxiosError would be written with its request config, auth header included, + // and the query would come back after a restart as an error + const raw = JSON.stringify(persisted); + assert.match(raw, /SECRET-TOKEN/); + const restoredRaw = new QueryClient(); + hydrate(restoredRaw, JSON.parse(raw).clientState); + assert.equal(restoredRaw.getQueryState(key)?.status, "error"); + + const stored = JSON.stringify(withoutRequestErrors(persisted)); + assert.doesNotMatch(stored, /SECRET-TOKEN|Network Error/); + const restored = new QueryClient(); + hydrate(restored, JSON.parse(stored).clientState); + assert.equal(restored.getQueryState(key)?.status, "success"); + assert.equal(restored.getQueryState(key)?.error, null); + assert.deepEqual(restored.getQueryData(key), [{ id: "a" }]); + }); + + test("successful queries and failed ones without data are left as they are", () => { + const ok = { + queryKey: ["a"], + queryHash: "a", + state: { status: "success", data: 1, error: null }, + }; + const failedNoData = { + queryKey: ["b"], + queryHash: "b", + state: { status: "error", data: undefined, error: {} }, + }; + const out = withoutRequestErrors({ + timestamp: 0, + buster: "", + clientState: { queries: [ok, failedNoData], mutations: [] }, + } as any); + assert.deepEqual(out.clientState.queries, [ok, failedNoData]); + }); + + test("a paused mutation keeps its payload but not the error of its last attempt", () => { + const out = withoutRequestErrors({ + timestamp: 0, + buster: "", + clientState: { + queries: [], + mutations: [ + { + mutationKey: ["incident-reports", "add"], + state: { + variables: { id: "r1" }, + isPaused: true, + error: null, + failureReason: networkError(), + }, + }, + ], + }, + } as any); + const [mutation] = out.clientState.mutations; + assert.deepEqual(mutation.state.variables, { id: "r1" }); + assert.equal(mutation.state.failureReason, null); + assert.doesNotMatch(JSON.stringify(out), /SECRET-TOKEN/); + }); +}); diff --git a/mobile/toast.config.js b/mobile/toast.config.js index 55eec7d20..1c64ed3bb 100644 --- a/mobile/toast.config.js +++ b/mobile/toast.config.js @@ -17,7 +17,7 @@ export const toastConfig = { style={{ borderLeftColor: "hsl(142, 71%, 27%)", height: "auto", - backgroundColor: "whsl(142, 71%, 27%)", + backgroundColor: "hsl(142, 71%, 27%)", maxWidth: "90%", }} /> diff --git a/mobile/tsconfig.json b/mobile/tsconfig.json index bbcf2bf77..2df57823d 100644 --- a/mobile/tsconfig.json +++ b/mobile/tsconfig.json @@ -2,7 +2,9 @@ "extends": "expo/tsconfig.base", "compilerOptions": { "strict": true, - "experimentalDecorators": true + "experimentalDecorators": true, + // tests run with node --test, which needs the .ts extension in imports (noEmit: never compiled) + "allowImportingTsExtensions": true // "strictPropertyInitialization": false } }