Enhance self-serve image loading with preload and error handling
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 }"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user