Enhance self-serve image loading with preload and error handling

This commit is contained in:
Jeppe Bundgaard
2026-06-03 23:53:54 +02:00
parent 390a29ef55
commit 3f01d38676
7 changed files with 156 additions and 47 deletions
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, watch } from "vue";
import { BIcon, BSkeleton } from "buefy";
import { computed, ref, watch } from "vue";
import { BIcon } from "buefy";
import SelfServeTaskList from "@/components/displays/selfServe/SelfServeTaskList.vue";
const props = defineProps<{
@@ -19,9 +19,16 @@ const emit = defineEmits<{
}>();
const isDynamicImageLoading = ref(false);
const failedDynamicImageUrl = ref<string | null>(null);
const showDynamicImageFrame = computed(
() => !!props.dynamicImageUrl
&& !isDynamicImageLoading.value
&& failedDynamicImageUrl.value !== props.dynamicImageUrl
);
watch(() => props.dynamicImageUrl, (dynamicImageUrl) => {
isDynamicImageLoading.value = !!dynamicImageUrl;
failedDynamicImageUrl.value = null;
}, { immediate: true });
const emitToggleTask = (taskId: number, value: boolean) => {
@@ -38,6 +45,7 @@ const onDynamicImageLoad = () => {
const onDynamicImageError = () => {
isDynamicImageLoading.value = false;
failedDynamicImageUrl.value = props.dynamicImageUrl;
emit("clear-dynamic-image");
};
</script>
@@ -58,26 +66,28 @@ const onDynamicImageError = () => {
<span class="ml-2">{{ $t("self_wash.loading_data") }}</span>
</div>
<img
v-if="dynamicImageUrl && isDynamicImageLoading"
:key="`${dynamicImageUrl}:preload`"
:src="dynamicImageUrl"
alt=""
data-testid="self-serve-dynamic-image-preload"
class="self-serve-dynamic-image-preload"
@load="onDynamicImageLoad"
@error="onDynamicImageError"
/>
<div
v-if="dynamicImageUrl"
v-if="showDynamicImageFrame"
class="self-serve-dynamic-image-frame mb-4"
:class="{ 'is-loading': isDynamicImageLoading }"
data-testid="self-serve-dynamic-image-frame"
>
<b-skeleton
v-if="isDynamicImageLoading"
class="self-serve-dynamic-image-skeleton"
width="100%"
height="100%"
data-testid="self-serve-dynamic-image-skeleton"
/>
<img
:key="dynamicImageUrl"
:src="dynamicImageUrl"
alt="Machine status"
data-testid="self-serve-dynamic-image"
class="self-serve-dynamic-image"
:class="{ 'is-loading': isDynamicImageLoading }"
@load="onDynamicImageLoad"
@error="onDynamicImageError"
/>
@@ -107,19 +117,20 @@ const onDynamicImageError = () => {
overflow: hidden;
}
.self-serve-dynamic-image-preload {
position: absolute;
width: 0;
height: 0;
opacity: 0;
pointer-events: none;
}
@media screen and (min-width: 769px) {
.self-serve-dynamic-image-frame {
max-width: 640px;
}
}
.self-serve-dynamic-image-skeleton {
position: absolute;
inset: 0;
overflow: hidden;
border-radius: 4px;
}
.self-serve-dynamic-image {
width: 100%;
height: 100%;
@@ -128,8 +139,4 @@ const onDynamicImageError = () => {
object-fit: contain;
transition: opacity 120ms ease;
}
.self-serve-dynamic-image.is-loading {
opacity: 0;
}
</style>
+51 -1
View File
@@ -271,6 +271,55 @@ const mergeByNumericId = (existingItems, incomingItems) => {
return merged;
};
const taskStableKey = (task) =>
String(task?.task ?? "")
.trim()
.toLowerCase();
const mergeTaskSets = (existingTasks, incomingTasks) => {
if (!Array.isArray(existingTasks) || existingTasks.length === 0) {
return Array.isArray(incomingTasks) ? [...incomingTasks] : [];
}
if (!Array.isArray(incomingTasks) || incomingTasks.length === 0) {
return [...existingTasks];
}
const usedExistingIndexes = new Set();
return incomingTasks.map((incomingTask) => {
const incomingId = extractTaskId(incomingTask);
let existingIndex = -1;
if (incomingId) {
existingIndex = existingTasks.findIndex((task) => extractTaskId(task) === incomingId);
} else {
const incomingKey = taskStableKey(incomingTask);
existingIndex = existingTasks.findIndex(
(task, index) => !usedExistingIndexes.has(index) && taskStableKey(task) === incomingKey
);
}
if (existingIndex === -1) {
return incomingTask;
}
usedExistingIndexes.add(existingIndex);
const existingTask = existingTasks[existingIndex];
return {
...existingTask,
...incomingTask,
id: incomingTask.id || existingTask.id,
task_id: incomingTask.task_id || existingTask.task_id,
attachments: Array.isArray(incomingTask.attachments) && incomingTask.attachments.length > 0
? incomingTask.attachments
: existingTask.attachments,
_attachmentsLoaded: incomingTask._attachmentsLoaded || existingTask._attachmentsLoaded,
};
});
};
export function useSelfServeLogic() {
const loading = ref(false);
const requestError = ref(null);
@@ -537,8 +586,9 @@ export function useSelfServeLogic() {
const normalizedTasks = summaryData.tasks
.map(normalizeTask)
.sort((a, b) => a.order_priority - b.order_priority);
const mergedTasks = mergeTaskSets(tasks.value, normalizedTasks);
const hydratedTasks = await hydrateTaskAttachments(normalizedTasks);
const hydratedTasks = await hydrateTaskAttachments(mergedTasks);
if (!isFetchRequestActive(requestId)) {
return false;
}
+1 -5
View File
@@ -19,7 +19,6 @@ export function useWashFlowState(options) {
customerNumberInput,
licensePlateInput,
vehicleTypeSelect,
availableProductIds,
isCustomerNumberRequired,
radioLaneOption,
nearestDepartment,
@@ -58,10 +57,7 @@ export function useWashFlowState(options) {
}
const selectedVehicleTypeId = vehicleTypeSelect.value;
const allowedProductIds = new Set((availableProductIds.value || []).map((productId) => String(productId)));
const hasSelectedVehicleType = selectedVehicleTypeId !== null && selectedVehicleTypeId !== undefined;
const hasAllowedVehicleType = hasSelectedVehicleType
&& (allowedProductIds.size === 0 || allowedProductIds.has(String(selectedVehicleTypeId)));
const customerNumberValue = customerNumberInput.value;
const requiresCustomerNumber = isCustomerNumberRequired?.value ?? false;
const customerNumberValid = !requiresCustomerNumber
@@ -69,7 +65,7 @@ export function useWashFlowState(options) {
|| customerNumberValue === undefined
|| String(customerNumberValue).trim() !== "";
const licensePlateValid = !!(licensePlateInput.value && licensePlateInput.value.trim() !== "");
if (!hasAllowedVehicleType) {
if (!hasSelectedVehicleType) {
return false;
}
@@ -1860,6 +1860,7 @@ watch(
<template v-if="doesCurrentDepartmentSelectionHaveSelfServeEnabled">
<b-steps
:key="currentStep"
v-model="currentStep"
class="self-serve-flow-steps"
:class="{ 'self-serve-flow-steps--question-review': currentStep === steps.QUESTIONS }"
+23 -17
View File
@@ -25,32 +25,37 @@ const mountTasksStep = (props = {}) =>
});
describe("SelfServeTasksStep", () => {
it("shows a Buefy skeleton until the dynamic image loads", async () => {
it("preloads the dynamic image without reserving frame space until it loads", async () => {
const wrapper = mountTasksStep();
expect(wrapper.get('[data-testid="self-serve-dynamic-image-frame"]').classes()).toContain("is-loading");
expect(wrapper.get('[data-testid="self-serve-dynamic-image-skeleton"]').exists()).toBe(true);
expect(wrapper.get('[data-testid="self-serve-dynamic-image"]').classes()).toContain("is-loading");
expect(wrapper.find('[data-testid="self-serve-dynamic-image-frame"]').exists()).toBe(false);
expect(wrapper.get('[data-testid="self-serve-dynamic-image-preload"]').attributes("src")).toBe(
"https://cdn.example.test/dynamic.png"
);
await wrapper.get('[data-testid="self-serve-dynamic-image"]').trigger("load");
await wrapper.get('[data-testid="self-serve-dynamic-image-preload"]').trigger("load");
expect(wrapper.get('[data-testid="self-serve-dynamic-image-frame"]').classes()).not.toContain("is-loading");
expect(wrapper.find('[data-testid="self-serve-dynamic-image-skeleton"]').exists()).toBe(false);
expect(wrapper.get('[data-testid="self-serve-dynamic-image"]').classes()).not.toContain("is-loading");
expect(wrapper.get('[data-testid="self-serve-dynamic-image-frame"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="self-serve-dynamic-image-preload"]').exists()).toBe(false);
expect(wrapper.get('[data-testid="self-serve-dynamic-image"]').attributes("src")).toBe(
"https://cdn.example.test/dynamic.png"
);
});
it("shows the skeleton again when the dynamic image URL changes", async () => {
it("preloads again without showing a frame when the dynamic image URL changes", async () => {
const wrapper = mountTasksStep();
await wrapper.get('[data-testid="self-serve-dynamic-image"]').trigger("load");
expect(wrapper.find('[data-testid="self-serve-dynamic-image-skeleton"]').exists()).toBe(false);
await wrapper.get('[data-testid="self-serve-dynamic-image-preload"]').trigger("load");
expect(wrapper.get('[data-testid="self-serve-dynamic-image"]').attributes("src")).toBe(
"https://cdn.example.test/dynamic.png"
);
await wrapper.setProps({
dynamicImageUrl: "https://cdn.example.test/dynamic-step-2.png",
});
expect(wrapper.get('[data-testid="self-serve-dynamic-image-skeleton"]').exists()).toBe(true);
expect(wrapper.get('[data-testid="self-serve-dynamic-image"]').attributes("src")).toBe(
expect(wrapper.find('[data-testid="self-serve-dynamic-image-frame"]').exists()).toBe(false);
expect(wrapper.get('[data-testid="self-serve-dynamic-image-preload"]').attributes("src")).toBe(
"https://cdn.example.test/dynamic-step-2.png"
);
});
@@ -58,18 +63,19 @@ describe("SelfServeTasksStep", () => {
it("clears loading state and emits clear-dynamic-image on image errors", async () => {
const wrapper = mountTasksStep();
await wrapper.get('[data-testid="self-serve-dynamic-image"]').trigger("error");
await wrapper.get('[data-testid="self-serve-dynamic-image-preload"]').trigger("error");
expect(wrapper.find('[data-testid="self-serve-dynamic-image-skeleton"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="self-serve-dynamic-image-frame"]').exists()).toBe(false);
expect(wrapper.emitted("clear-dynamic-image")).toEqual([[]]);
});
it("does not render a skeleton when there is no dynamic image URL", () => {
it("does not render dynamic image elements when there is no dynamic image URL", () => {
const wrapper = mountTasksStep({
dynamicImageUrl: null,
});
expect(wrapper.find('[data-testid="self-serve-dynamic-image-skeleton"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="self-serve-dynamic-image-preload"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="self-serve-dynamic-image-frame"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="self-serve-dynamic-image"]').exists()).toBe(false);
});
});
+46
View File
@@ -91,6 +91,52 @@ describe("useSelfServeLogic", () => {
});
});
it("preserves preview task ids when wash summary omits task ids", async () => {
mocks.previewAllowed.mockResolvedValue({
allowed: true,
machine_available: true,
lane: { id: 7, name: "Lane 7" },
session: { id: 91, status: "READY_FOR_MACHINE_START" },
questions: [{ id: 1, question: "Machine wash?", answer: false, order_priority: 1 }],
tasks: [
{
id: 802001,
task: "Vælg program #2 på dial",
condition_id: 702001,
order_priority: 10,
services: ["MACHINE"],
},
{
id: 802006,
task: "Tryk knap #6 (tagbørste program)",
condition_id: 702003,
order_priority: 36,
services: ["MACHINE"],
},
],
conditions: [],
rules: [],
allowed_services: ["MACHINE"],
});
mocks.washSummary.mockResolvedValue({
session: { id: 91, status: "READY_FOR_MACHINE_START" },
lane: { id: 7, name: "Lane 7" },
questions: [{ id: 1, question: "Machine wash?", answer: false, order_priority: 1 }],
tasks: [
{ task: "Vælg program #2 på dial", condition_id: 702001, order_priority: 10, services: ["MACHINE"] },
{ task: "Tryk knap #6 (tagbørste program)", condition_id: 702003, order_priority: 36, services: ["MACHINE"] },
],
events: [],
});
const logic = useSelfServeLogic();
await logic.fetchSelfServeData(1, 9, 7, "ab12345");
expect(logic.tasks.value.map((task) => task.id)).toEqual([802001, 802006]);
expect(logic.activeTasks.value.map((task) => task.id)).toEqual([802001, 802006]);
});
it("does not infer machine permission from summary tasks when allowed services are explicitly empty", async () => {
const machineButtonTask = {
id: 5,
+4 -1
View File
@@ -35,7 +35,7 @@ describe("useWashFlowState", () => {
};
};
it("validates the vehicle step against customer, registration, and allowed product ids", () => {
it("validates the vehicle step against customer, registration, and selected vehicle type", () => {
const { flow, state } = createState();
expect(flow.clickableSteps[WASH_STEPS.VEHICLE]()).toBe(true);
@@ -48,6 +48,9 @@ describe("useWashFlowState", () => {
expect(flow.clickableSteps[WASH_STEPS.VEHICLE]()).toBe(true);
state.vehicleTypeSelect.value = 9;
expect(flow.clickableSteps[WASH_STEPS.VEHICLE]()).toBe(true);
state.vehicleTypeSelect.value = null;
expect(flow.clickableSteps[WASH_STEPS.VEHICLE]()).toBe(false);
});