Enhance self-serve functionality with dynamic images, gate handling, and machine-scoped vehicle type filtering:

- Added dynamic image previews with query parameter handling and error recovery in `SelfServeTryModal.vue`.
- Introduced outside gate controls (`Entrance` and `Exit`) in `SelfServeMachineRelayControls.vue` with corresponding command handling logic.
- Enhanced vehicle type scoping logic in `SelfServeTasksPagination.vue` and `SelfServeConditionsPagination.vue` to ensure machine-type filtering consistency.
- Updated related unit tests to verify new dynamic image logic, gate controls, and scoping behaviors.
This commit is contained in:
Jeppe Bundgaard
2026-03-26 14:13:15 +01:00
parent 3e84421913
commit d354ce7737
12 changed files with 318 additions and 9 deletions
@@ -6,6 +6,7 @@ import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { useSelfServeLogic } from "@/composables/useSelfServeLogic";
import SelfServeQuestionCards from "@/components/displays/selfServe/SelfServeQuestionCards.vue";
import SelfServeTaskList from "@/components/displays/selfServe/SelfServeTaskList.vue";
import { API_URL } from "@/config";
const { t: $t } = useI18n();
@@ -133,6 +134,79 @@ const canClearAnswers = computed(() => (
&& normalizedReg.value.length >= 2
&& !loading.value
));
const hideDynamicImage = ref(false);
const parseNonNegativeInt = (value) => {
const parsed = parseInt(value);
if (Number.isNaN(parsed) || parsed < 0) {
return null;
}
return parsed;
};
const dynamicImageButtons = computed(() => {
const buttonIds = new Set();
activeTasks.value.forEach((task) => {
if (!Array.isArray(task?.buttons)) {
return;
}
task.buttons.forEach((button) => {
const buttonId = parseInt(button);
if (!Number.isNaN(buttonId) && buttonId >= 0 && buttonId <= 11) {
buttonIds.add(buttonId);
}
});
});
return [...buttonIds];
});
const dynamicImageThumbPosition = computed(() => {
for (const task of activeTasks.value) {
const parsedPosition = parseNonNegativeInt(task?.dynamic_images_vehicle_type);
if (parsedPosition !== null) {
return parsedPosition;
}
}
return null;
});
const hasDynamicImageContext = computed(() => (
dynamicImageButtons.value.length > 0 || dynamicImageThumbPosition.value !== null
));
const dynamicImageUrl = computed(() => {
const departmentId = parseInt(props.departmentId);
const laneId = parseInt(selectedLaneId.value);
if (Number.isNaN(departmentId) || departmentId <= 0 || Number.isNaN(laneId) || laneId <= 0 || !hasDynamicImageContext.value) {
return null;
}
const params = new URLSearchParams({
department: String(departmentId),
lane: String(laneId),
current_step: "0",
buttons: JSON.stringify(dynamicImageButtons.value),
});
const selectedVehicleType = parseInt(selectedVehicleTypeId.value);
if (!Number.isNaN(selectedVehicleType) && selectedVehicleType > 0) {
params.set("vehicle_type", String(selectedVehicleType));
}
if (dynamicImageThumbPosition.value !== null) {
params.set("thumb_position", String(dynamicImageThumbPosition.value));
}
return `${API_URL}/department/lanes/dynamic-image?${params.toString()}`;
});
const displayedDynamicImageUrl = computed(() => (
hideDynamicImage.value ? null : dynamicImageUrl.value
));
const loadLanes = async () => {
const lanes = await SessionUser.objects.department_lanes.get.all();
@@ -215,6 +289,10 @@ const closeModal = () => {
emit("closeModal");
};
const onDynamicImageError = () => {
hideDynamicImage.value = true;
};
onMounted(async () => {
await loadLanes();
await loadVehicleTypes();
@@ -232,6 +310,10 @@ watch(() => [selectedLaneId.value, reg.value, normalizeVehicleTypeId(selectedVeh
await refresh();
});
watch(dynamicImageUrl, () => {
hideDynamicImage.value = false;
});
</script>
<template>
@@ -338,6 +420,16 @@ watch(() => [selectedLaneId.value, reg.value, normalizeVehicleTypeId(selectedVeh
<div class="box" style="height: 100%">
<h4 class="title is-5">Tasks og session</h4>
<div v-if="displayedDynamicImageUrl" class="mb-4">
<img
:src="displayedDynamicImageUrl"
alt="Machine status preview"
data-testid="self-serve-try-dynamic-image"
style="max-width: 100%; height: auto; border-radius: 4px; display: block; margin-left: auto; margin-right: auto;"
@error="onDynamicImageError"
/>
</div>
<SelfServeTaskList
:tasks="activeTasks"
:completedTasks="emptyCompletedTasks"
@@ -76,9 +76,15 @@ const sortedList = computed(() => {
return false;
}
if (vehicleTypeFilter && itemMachineType === 0 && itemProduct !== 0 && itemProduct !== vehicleTypeFilter) {
if (vehicleTypeFilter) {
if (itemMachineType !== 0) {
if (itemMachineType !== vehicleTypeFilter) {
return false;
}
} else if (itemProduct !== 0 && itemProduct !== vehicleTypeFilter) {
return false;
}
}
if (conditionFilter && itemCondition !== conditionFilter) {
return false;
@@ -82,9 +82,15 @@ const sortedList = computed(() => {
return false;
}
if (vehicleTypeFilter && itemMachineType === 0 && itemProduct !== 0 && itemProduct !== vehicleTypeFilter) {
if (vehicleTypeFilter) {
if (itemMachineType !== 0) {
if (itemMachineType !== vehicleTypeFilter) {
return false;
}
} else if (itemProduct !== 0 && itemProduct !== vehicleTypeFilter) {
return false;
}
}
if (conditionFilter && itemCondition !== conditionFilter) {
return false;
+1 -1
View File
@@ -34,7 +34,7 @@ export function useWashSessionActions(options) {
activeTasks.value.forEach((task) => {
if (completedTasks.value[task.id]) {
return;
//return;
}
(task.buttons || []).forEach((button) => {
@@ -297,8 +297,9 @@ const matchesScopedProduct = (item) => {
if (!scopedProductId.value) {
return true;
}
if (parseNullableInt(item.machine_type_id)) {
return true;
const machineTypeId = parseNullableInt(item.machine_type_id);
if (machineTypeId) {
return machineTypeId === scopedProductId.value;
}
const product = parseIntOrZero(item.product);
return product === 0 || product === scopedProductId.value;
@@ -15,7 +15,7 @@ export type RelayStatus = {
online: boolean;
on: boolean;
};
export type LaneCommand = "START" | "STOP" | "RESET" | "RESERVE" | "RELEASE";
export type LaneCommand = "START" | "STOP" | "RESET" | "RESERVE" | "RELEASE" | "OPEN_PROPERTY_ACCESS_GATE" | "OPEN_PROPERTY_EXIT_GATE";
export type LaneGate = "ENTRANCE" | "EXIT";
export type LaneCommandPayload = {
customer_number?: number | null;
@@ -23,6 +23,8 @@ type LaneCommandLoadingState = {
forceDisable: boolean;
openEntranceGate: boolean;
openExitGate: boolean;
openOutsideEntranceGate: boolean;
openOutsideExitGate: boolean;
};
type RelayDisplayStatus = "ON" | "OFF" | "OFFLINE" | "MAINTENANCE";
type ForceEnableOptions = {
@@ -35,6 +37,7 @@ type InProgressWashDetails = {
session: Record<string, unknown> | null;
customer: Record<string, unknown> | null;
vehicle: Record<string, unknown> | null;
wash_in_progress: boolean;
};
const DEFAULT_POLL_INTERVAL_MS = 7000;
@@ -68,6 +71,8 @@ const createInitialCommandLoadingState = (): LaneCommandLoadingState => ({
forceDisable: false,
openEntranceGate: false,
openExitGate: false,
openOutsideEntranceGate: false,
openOutsideExitGate: false,
});
const toLaneId = (laneId: number): number => {
@@ -221,6 +226,7 @@ const normalizeInProgressDetails = (
session: null,
customer: null,
vehicle: null,
wash_in_progress: false,
};
}
@@ -241,6 +247,7 @@ const normalizeInProgressDetails = (
session,
customer,
vehicle,
wash_in_progress: Boolean(source.wash_in_progress),
};
};
@@ -456,6 +463,27 @@ export const openGate = async (
});
};
export const openOutsideGate = async (
laneId: number,
gate: "ENTRANCE" | "EXIT"
): Promise<any> => {
const normalizedLaneId = toLaneId(laneId);
const loadingKey: CommandLoadingKey = gate === "ENTRANCE"
? "openOutsideEntranceGate"
: "openOutsideExitGate";
return runLaneAction(normalizedLaneId, loadingKey, async () => {
const command = gate === "ENTRANCE"
? "OPEN_PROPERTY_ACCESS_GATE"
: "OPEN_PROPERTY_EXIT_GATE";
const response = await executeLaneCommand(normalizedLaneId, command);
return extractPayload(response);
});
};
export const openOutsideEntranceGate = (laneId: number): Promise<any> => openOutsideGate(laneId, "ENTRANCE");
export const openOutsideExitGate = (laneId: number): Promise<any> => openOutsideGate(laneId, "EXIT");
export const openEntranceGate = (laneId: number): Promise<any> => openGate(laneId, "ENTRANCE");
export const openExitGate = (laneId: number): Promise<any> => openGate(laneId, "EXIT");
@@ -550,6 +578,8 @@ export const useMachineConnectivity = (laneId: number) => {
openGate: (gate: LaneGate) => openGate(normalizedLaneId, gate),
openEntranceGate: () => openEntranceGate(normalizedLaneId),
openExitGate: () => openExitGate(normalizedLaneId),
openOutsideEntranceGate: () => openOutsideEntranceGate(normalizedLaneId),
openOutsideExitGate: () => openOutsideExitGate(normalizedLaneId),
forceEnableMachine: (options: ForceEnableOptions = {}) => forceEnableMachine(normalizedLaneId, options),
forceDisableMachine: (licensePlate: string | null = null) => forceDisableMachine(normalizedLaneId, licensePlate),
startPolling: (intervalMs = DEFAULT_POLL_INTERVAL_MS) => startPolling(normalizedLaneId, intervalMs),
@@ -43,6 +43,10 @@ const hasGateLoading = computed(() => (
commandLoading.value.openEntranceGate || commandLoading.value.openExitGate
));
const hasOutsideGateLoading = computed(() => (
commandLoading.value.openOutsideEntranceGate || commandLoading.value.openOutsideExitGate
))
const normalizeDisplayValue = (value: unknown): string | null => {
if (typeof value === "string") {
const normalized = value.trim();
@@ -247,7 +251,7 @@ onUnmounted(() => {
<p class="divider-text">Wash In Progress</p>
</div>
<div class="box is-shadowless p-3 mb-3">
<template v-if="inProgressLoading">
<template v-if="inProgressLoading && !inProgressDetails">
<p>Loading in-progress details...</p>
</template>
<template v-else-if="inProgressDetails?.in_progress">
@@ -365,6 +369,27 @@ onUnmounted(() => {
</button>
</div>
</div>
<!-- This sends a command to open the outside gate to the property, not to be confused with the entrance gate. -->
<div class="columns is-mobile">
<div class="column is-half">
<button
class="button is-warning is-small is-fullwidth"
:class="{ 'is-loading': commandLoading.openOutsideEntranceGate }"
:disabled="hasOutsideGateLoading"
@click="connectivity.openOutsideEntranceGate()">
Open outside entrance gate
</button>
</div>
<div class="column is-half">
<button
class="button is-danger is-small is-fullwidth"
:class="{ 'is-loading': commandLoading.openOutsideExitGate }"
:disabled="hasOutsideGateLoading"
@click="connectivity.openOutsideExitGate()">
Open outside exit gate
</button>
</div>
</div>
<p v-if="error" class="help is-danger mt-2">{{ error }}</p>
</div>
</template>
@@ -200,4 +200,42 @@ describe("SelfServeMachineRelayControls gate controls", () => {
expect(wrapper.text()).toContain("Vehicle:");
expect(wrapper.text()).toContain("AB12345");
});
it("keeps existing in-progress content visible while refresh is loading", async () => {
mocks.useMachineConnectivity.mockImplementation(() => ({
...buildConnectivity(),
inProgressDetails: ref({
lane_id: 5,
in_progress: true,
session: { reg: "CD67890", customer_number: 2002 },
customer: { display_name: "Jane Doe", customer_number: 2002 },
vehicle: { reg: "CD67890" },
}),
inProgressLoading: ref(true),
}));
const wrapper = mount(SelfServeMachineRelayControls, {
props: {
machine: {
id: 5,
name: "Lane 5",
status: "ONLINE",
relay_in_id: "in",
relay_out_id: "out",
},
},
global: {
stubs: {
SelfServeMachineStatus: { template: "<div />" },
BSwitch: { template: "<div />" },
},
},
});
await flushMicrotasks();
expect(wrapper.text()).toContain("Customer:");
expect(wrapper.text()).toContain("Jane Doe");
expect(wrapper.text()).not.toContain("Loading in-progress details...");
});
});
@@ -0,0 +1,27 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const readSource = (relativePath) => readFileSync(join(process.cwd(), relativePath), "utf8");
describe("self-serve pagination machine scope filtering", () => {
it("enforces machine-scoped vehicle type matching in tasks pagination", () => {
const source = readSource("src/components/displays/pagination/models/DepartmentDashboard/SelfServeTasksPagination.vue");
expect(source).toContain("if (vehicleTypeFilter) {");
expect(source).toContain("if (itemMachineType !== 0) {");
expect(source).toContain("if (itemMachineType !== vehicleTypeFilter) {");
expect(source).toContain("} else if (itemProduct !== 0 && itemProduct !== vehicleTypeFilter) {");
expect(source).not.toContain("if (vehicleTypeFilter && itemMachineType === 0 && itemProduct !== 0 && itemProduct !== vehicleTypeFilter)");
});
it("enforces machine-scoped vehicle type matching in conditions pagination", () => {
const source = readSource("src/components/displays/pagination/models/DepartmentDashboard/SelfServeConditionsPagination.vue");
expect(source).toContain("if (vehicleTypeFilter) {");
expect(source).toContain("if (itemMachineType !== 0) {");
expect(source).toContain("if (itemMachineType !== vehicleTypeFilter) {");
expect(source).toContain("} else if (itemProduct !== 0 && itemProduct !== vehicleTypeFilter) {");
expect(source).not.toContain("if (vehicleTypeFilter && itemMachineType === 0 && itemProduct !== 0 && itemProduct !== vehicleTypeFilter)");
});
});
@@ -21,6 +21,9 @@ describe("self-serve studio vehicle type scope", () => {
expect(studioSource).toContain("const scopedQuestions = computed(() => questions.value.filter((entry) => matchesScopedProduct(entry)));");
expect(studioSource).toContain("const scopedConditions = computed(() => conditions.value.filter((entry) => matchesScopedProduct(entry)));");
expect(studioSource).toContain("const scopedTasks = computed(() => tasks.value.filter((entry) => matchesScopedProduct(entry)));");
expect(studioSource).toContain("const machineTypeId = parseNullableInt(item.machine_type_id);");
expect(studioSource).toContain("return machineTypeId === scopedProductId.value;");
expect(studioSource).not.toContain("if (parseNullableInt(item.machine_type_id)) {\n return true;\n }\n const product = parseIntOrZero(item.product);");
expect(studioSource).toContain("product: scopedProductId.value || 0,");
});
+81
View File
@@ -108,6 +108,7 @@ describe("SelfServeTryModal", () => {
mocks.questions.value = [];
mocks.visibleQuestions.value = [];
mocks.answers.value = {};
mocks.activeTasks.value = [];
mocks.currentQuestion.value = null;
mocks.evaluateCondition.mockReturnValue(true);
});
@@ -186,6 +187,86 @@ describe("SelfServeTryModal", () => {
});
});
it("renders dynamic image preview with expected query parameters when context exists", async () => {
mocks.activeTasks.value = [
{
id: 1,
buttons: [0, 2, 2, -1, 99],
dynamic_images_vehicle_type: 5,
},
];
const wrapper = mountWithApp(SelfServeTryModal, {
props: {
departmentId: 9,
laneId: 3,
vehicleTypeId: 3,
},
global: {
stubs: {
SelfServeQuestionCards: true,
SelfServeTaskList: true,
},
},
});
await flushPromises();
const image = wrapper.get('[data-testid="self-serve-try-dynamic-image"]');
const imageUrl = new URL(image.attributes("src"));
expect(imageUrl.pathname).toBe("/department/lanes/dynamic-image");
expect(imageUrl.searchParams.get("department")).toBe("9");
expect(imageUrl.searchParams.get("lane")).toBe("3");
expect(imageUrl.searchParams.get("current_step")).toBe("0");
expect(imageUrl.searchParams.get("vehicle_type")).toBe("3");
expect(imageUrl.searchParams.get("thumb_position")).toBe("5");
expect(JSON.parse(imageUrl.searchParams.get("buttons"))).toEqual([0, 2]);
});
it("does not render dynamic image preview without dynamic-image context", async () => {
mocks.activeTasks.value = [
{
id: 1,
buttons: [-1, 12, 99],
dynamic_images_vehicle_type: null,
},
];
const wrapper = mountModal();
await flushPromises();
expect(wrapper.find('[data-testid="self-serve-try-dynamic-image"]').exists()).toBe(false);
});
it("hides dynamic image on error and restores it when image context changes", async () => {
mocks.activeTasks.value = [
{
id: 1,
buttons: [1],
dynamic_images_vehicle_type: null,
},
];
const wrapper = mountModal();
await flushPromises();
await wrapper.get('[data-testid="self-serve-try-dynamic-image"]').trigger("error");
await flushPromises();
expect(wrapper.find('[data-testid="self-serve-try-dynamic-image"]').exists()).toBe(false);
await wrapper.get('[data-testid="self-serve-try-vehicle-type"]').setValue("3");
await flushPromises();
const image = wrapper.get('[data-testid="self-serve-try-dynamic-image"]');
const imageUrl = new URL(image.attributes("src"));
expect(imageUrl.searchParams.get("vehicle_type")).toBe("3");
expect(JSON.parse(imageUrl.searchParams.get("buttons"))).toEqual([1]);
});
it("does not duplicate the current question in the question cards list", async () => {
mocks.currentQuestion.value = { id: 11, question: "Question 11" };
mocks.visibleQuestions.value = [