diff --git a/src/views/dashboards/userDashboard/wash/MyWashStart.vue b/src/views/dashboards/userDashboard/wash/MyWashStart.vue index d062d659..e1ae2d8d 100644 --- a/src/views/dashboards/userDashboard/wash/MyWashStart.vue +++ b/src/views/dashboards/userDashboard/wash/MyWashStart.vue @@ -48,6 +48,9 @@ const currentStep = ref(0); const hideDynamicImage = ref(false); const vehicleStepError = ref(null); const washActionError = ref(null); +const questionStepError = ref(null); +const pendingQuestionSyncs = ref([]); +const failedQuestionSyncs = ref([]); const isSelfServeRetrying = ref(false); const isCompletingWash = ref(false); const isVehicleStepNextLoading = ref(false); @@ -296,6 +299,38 @@ const { isServiceAllowed, }); +const normalizeQuestionId = (questionId: any) => Number.parseInt(String(questionId ?? 0), 10); + +const visibleQuestionIds = computed( + () => + new Set( + visibleQuestions.value + .map((question: any) => normalizeQuestionId(question?.id)) + .filter((questionId: number) => Number.isInteger(questionId) && questionId > 0) + ) +); + +const hasVisiblePendingQuestionSync = computed(() => + pendingQuestionSyncs.value.some((questionId) => visibleQuestionIds.value.has(questionId)) +); + +const hasVisibleFailedQuestionSync = computed(() => + failedQuestionSyncs.value.some((questionId) => visibleQuestionIds.value.has(questionId)) +); + +const hasBlockingVisibleQuestionSync = computed( + () => hasVisiblePendingQuestionSync.value || hasVisibleFailedQuestionSync.value +); + +const allVisibleQuestionsAnswered = computed( + () => + !hasBlockingVisibleQuestionSync.value && + (selfServeQuestionsAnswered.value || + visibleQuestions.value.every( + (question) => answers.value[question.id] === true || answers.value[question.id] === false + )) +); + const { steps, clickableSteps, isNextButtonDisabled, handleConfirmNext, targetStepForStart } = useWashFlowState({ currentStep, washInProgress, @@ -306,13 +341,7 @@ const { steps, clickableSteps, isNextButtonDisabled, handleConfirmNext, targetSt radioLaneOption, radioWashType, nearestDepartment, - allVisibleQuestionsAnswered: computed( - () => - selfServeQuestionsAnswered.value || - visibleQuestions.value.every( - (question) => answers.value[question.id] === true || answers.value[question.id] === false - ) - ), + allVisibleQuestionsAnswered, isLoadingSelfServeData, activeTasks: displayedActiveTasks, completedTasks, @@ -444,14 +473,6 @@ watch(dynamicImageUrl, () => { hideDynamicImage.value = false; }); -const allVisibleQuestionsAnswered = computed( - () => - selfServeQuestionsAnswered.value || - visibleQuestions.value.every( - (question) => answers.value[question.id] === true || answers.value[question.id] === false - ) -); - const hasAllowedVehicleTypeSelection = computed(() => { const selectedVehicleTypeId = vehicleTypeSelect.value; if (selectedVehicleTypeId === null || selectedVehicleTypeId === undefined) { @@ -1145,26 +1166,84 @@ const onToggleTask = (taskId: number, value: boolean) => { }; }; -const submitQuestionAnswer = async (questionId: number, value: boolean) => { - answerQuestion(questionId, value); +const addQuestionSyncId = (target: typeof pendingQuestionSyncs, questionId: number) => { + if (!target.value.includes(questionId)) { + target.value = [...target.value, questionId]; + } +}; +const removeQuestionSyncId = (target: typeof pendingQuestionSyncs, questionId: number) => { + target.value = target.value.filter((candidateId) => candidateId !== questionId); +}; + +const buildQuestionSyncPayload = (questionId: number, value: boolean) => { const laneId = getEffectiveLaneId(); if (!nearestDepartment.value || !laneId || !licensePlateInput.value?.trim()) { + return null; + } + + return { + departmentId: nearestDepartment.value.id, + laneId, + customerNumber: getNumericCustomerNumber(), + reg: licensePlateInput.value, + questionId, + value, + vehicleTypeId: vehicleTypeSelect.value || null, + }; +}; + +const syncQuestionAnswer = async (questionId: number, value: boolean) => { + const normalizedQuestionId = normalizeQuestionId(questionId); + if (!normalizedQuestionId) { + return false; + } + + const payload = buildQuestionSyncPayload(normalizedQuestionId, value); + if (!payload) { + return true; + } + + addQuestionSyncId(pendingQuestionSyncs, normalizedQuestionId); + removeQuestionSyncId(failedQuestionSyncs, normalizedQuestionId); + + try { + await syncVehicleAnswer(payload); + removeQuestionSyncId(failedQuestionSyncs, normalizedQuestionId); + if (!hasVisibleFailedQuestionSync.value) { + questionStepError.value = null; + } + return true; + } catch (error) { + console.error("Error synchronizing self-serve answer:", error); + addQuestionSyncId(failedQuestionSyncs, normalizedQuestionId); + questionStepError.value = extractErrorMessage(error, "Kunne ikke gemme dit svar. Prøv igen, før du fortsætter."); + return false; + } finally { + removeQuestionSyncId(pendingQuestionSyncs, normalizedQuestionId); + } +}; + +const submitQuestionAnswer = async (questionId: number, value: boolean) => { + answerQuestion(questionId, value); + await syncQuestionAnswer(questionId, value); +}; + +const retryFailedQuestionSyncs = async () => { + const retryQuestionIds = failedQuestionSyncs.value.filter((questionId) => visibleQuestionIds.value.has(questionId)); + if (retryQuestionIds.length === 0) { + questionStepError.value = null; return; } - try { - await syncVehicleAnswer({ - departmentId: nearestDepartment.value.id, - laneId, - customerNumber: getNumericCustomerNumber(), - reg: licensePlateInput.value, - questionId, - value, - vehicleTypeId: vehicleTypeSelect.value || null, - }); - } catch (error) { - console.error("Error synchronizing self-serve answer:", error); + for (const questionId of retryQuestionIds) { + const value = answers.value[questionId]; + if (value !== true && value !== false) { + removeQuestionSyncId(failedQuestionSyncs, questionId); + continue; + } + + await syncQuestionAnswer(questionId, value); } }; @@ -1172,7 +1251,14 @@ const toggleEditAnswers = () => { editAnswers.value = !editAnswers.value; }; +const isCurrentStepNextButtonDisabled = () => + isNextButtonDisabled() || (currentStep.value === steps.QUESTIONS && hasBlockingVisibleQuestionSync.value); + const onConfirmNext = async () => { + if (isCurrentStepNextButtonDisabled()) { + return; + } + const wasQuestionsStep = currentStep.value === steps.QUESTIONS; const previousQuestionReviewKey = confirmedQuestionReviewKey.value; @@ -1589,6 +1675,29 @@ watch( currentStep === steps.COMPLETED " > + +
+ {{ questionStepError }} + + {{ $t("common.try_again") }} + +
+
+ {{ $t("common.next") }} @@ -1744,7 +1853,7 @@ watch( icon-right="arrow-right" data-testid="self-serve-nav-confirm" :loading="isStartingWash" - :disabled="isStartingWash || isNextButtonDisabled()" + :disabled="isStartingWash || isCurrentStepNextButtonDisabled()" @click="onConfirmNext" > {{ $t(confirmActionLabelKey) }} diff --git a/tests/unit/my-wash-start.spec.js b/tests/unit/my-wash-start.spec.js index 6b334639..ba8c35e7 100644 --- a/tests/unit/my-wash-start.spec.js +++ b/tests/unit/my-wash-start.spec.js @@ -390,6 +390,9 @@ describe("MyWashStart", () => { mocks.addVehicle.mockClear(); mocks.sessionRequest.mockClear(); mocks.sessionRequest.mockResolvedValue(undefined); + mocks.handleConfirmNext.mockClear(); + mocks.isNextButtonDisabled.mockClear(); + mocks.isNextButtonDisabled.mockReturnValue(false); mocks.onStartWash.mockClear(); mocks.onStopWash.mockReset(); mocks.onStopWash.mockResolvedValue(true); @@ -540,6 +543,51 @@ describe("MyWashStart", () => { await flushPromises(); }); + it("surfaces failed question answer syncs and blocks question confirmation until retry succeeds", async () => { + mocks.syncVehicleAnswer.mockRejectedValueOnce(new Error("Question sync failed")).mockResolvedValueOnce(undefined); + + const wrapper = mountWithApp(MyWashStart, { + global: { + stubs: stubComponents, + }, + }); + + await flushPromises(); + await wrapper.get('[data-testid="emit-lane"]').trigger("click"); + await wrapper.get('[data-testid="emit-registration"]').trigger("click"); + await wrapper.get('[data-testid="emit-vehicle-type"]').trigger("click"); + await nextTick(); + await wrapper.get('[data-testid="self-serve-nav-next"]').trigger("click"); + await flushPromises(); + + await wrapper.get('[data-testid="emit-answer"]').trigger("click"); + await flushPromises(); + + expect(wrapper.get('[data-testid="self-serve-question-sync-error"]').text()).toContain("Question sync failed"); + + const confirmButton = wrapper.get('[data-testid="self-serve-nav-confirm"]'); + expect(confirmButton.attributes("disabled")).toBeDefined(); + + await confirmButton.trigger("click"); + expect(mocks.handleConfirmNext).not.toHaveBeenCalled(); + + await wrapper.get('[data-testid="self-serve-question-sync-retry"]').trigger("click"); + await flushPromises(); + + expect(mocks.syncVehicleAnswer).toHaveBeenCalledTimes(2); + expect(mocks.syncVehicleAnswer).toHaveBeenNthCalledWith(2, { + departmentId: 6, + laneId: 7, + customerNumber: 12345679, + reg: "AB12345", + questionId: 11, + value: true, + vehicleTypeId: 2, + }); + expect(wrapper.find('[data-testid="self-serve-question-sync-error"]').exists()).toBe(false); + expect(wrapper.get('[data-testid="self-serve-nav-confirm"]').attributes("disabled")).toBeUndefined(); + }); + it("shows disabled warning without showing loading state when department self-serve is unavailable", async () => { mocks.nearestDepartment.value = { ...mocks.nearestDepartment.value,