Merge pull request #112 from copenhagentruckwash/update-mywashstart.vue-for-answer-sync-state

Block wash question flow on failed answer sync
This commit is contained in:
Jeppe B
2026-06-02 13:43:22 +02:00
committed by GitHub
2 changed files with 188 additions and 31 deletions
@@ -48,6 +48,9 @@ const currentStep = ref(0);
const hideDynamicImage = ref(false);
const vehicleStepError = ref<string | null>(null);
const washActionError = ref<string | null>(null);
const questionStepError = ref<string | null>(null);
const pendingQuestionSyncs = ref<number[]>([]);
const failedQuestionSyncs = ref<number[]>([]);
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
"
>
<b-message
v-if="questionStepError"
type="is-danger"
has-icon
:closable="false"
data-testid="self-serve-question-sync-error"
>
<div class="is-flex is-align-items-center is-justify-content-space-between is-flex-wrap-wrap">
<span class="mr-3">{{ questionStepError }}</span>
<b-button
size="is-small"
type="is-danger is-light"
icon-pack="fas"
icon-left="sync-alt"
data-testid="self-serve-question-sync-retry"
:loading="hasVisiblePendingQuestionSync"
@click="retryFailedQuestionSyncs"
>
{{ $t("common.try_again") }}
</b-button>
</div>
</b-message>
<SelfServeQuestionsStep
:is-loading="isLoadingSelfServeData"
:visible-questions="visibleQuestions"
@@ -1714,7 +1823,7 @@ watch(
icon-right="arrow-right"
data-testid="self-serve-nav-next"
:loading="isVehicleStepNextLoading"
:disabled="isVehicleStepNextLoading || isNextButtonDisabled()"
:disabled="isVehicleStepNextLoading || isCurrentStepNextButtonDisabled()"
@click.prevent="onVehicleStepNext"
>
{{ $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) }}
+48
View File
@@ -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,