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:
@@ -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) }}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user