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:
@@ -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"
|
||||
|
||||
+7
-1
@@ -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;
|
||||
|
||||
+7
-1
@@ -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;
|
||||
|
||||
@@ -34,7 +34,7 @@ export function useWashSessionActions(options) {
|
||||
|
||||
activeTasks.value.forEach((task) => {
|
||||
if (completedTasks.value[task.id]) {
|
||||
return;
|
||||
//return;
|
||||
}
|
||||
|
||||
(task.buttons || []).forEach((button) => {
|
||||
|
||||
+3
-2
@@ -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;
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+30
@@ -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),
|
||||
|
||||
+26
-1
@@ -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,");
|
||||
});
|
||||
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user