diff --git a/src/components/displays/selfServe/SelfServeTasksStep.vue b/src/components/displays/selfServe/SelfServeTasksStep.vue
index c6b36181..2046fc1f 100644
--- a/src/components/displays/selfServe/SelfServeTasksStep.vue
+++ b/src/components/displays/selfServe/SelfServeTasksStep.vue
@@ -1,6 +1,6 @@
@@ -58,26 +66,28 @@ const onDynamicImageError = () => {
{{ $t("self_wash.loading_data") }}
+
+
-
![Machine status]()
@@ -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;
-}
diff --git a/src/composables/useSelfServeLogic.js b/src/composables/useSelfServeLogic.js
index a9c47327..e6f7930c 100644
--- a/src/composables/useSelfServeLogic.js
+++ b/src/composables/useSelfServeLogic.js
@@ -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;
}
diff --git a/src/composables/useWashFlowState.js b/src/composables/useWashFlowState.js
index 330f4442..16583cb2 100644
--- a/src/composables/useWashFlowState.js
+++ b/src/composables/useWashFlowState.js
@@ -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;
}
diff --git a/src/views/dashboards/userDashboard/wash/MyWashStart.vue b/src/views/dashboards/userDashboard/wash/MyWashStart.vue
index 21f1dc36..b90f7b00 100644
--- a/src/views/dashboards/userDashboard/wash/MyWashStart.vue
+++ b/src/views/dashboards/userDashboard/wash/MyWashStart.vue
@@ -1860,6 +1860,7 @@ watch(
});
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);
});
});
diff --git a/tests/unit/use-self-serve-logic.spec.js b/tests/unit/use-self-serve-logic.spec.js
index dc51cb37..79be33ca 100644
--- a/tests/unit/use-self-serve-logic.spec.js
+++ b/tests/unit/use-self-serve-logic.spec.js
@@ -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,
diff --git a/tests/unit/use-wash-flow-state.spec.js b/tests/unit/use-wash-flow-state.spec.js
index b43bf91c..162c01b7 100644
--- a/tests/unit/use-wash-flow-state.spec.js
+++ b/tests/unit/use-wash-flow-state.spec.js
@@ -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);
});