Add unit tests and services for Self-Serve Vehicle Step and Excel export:

- Introduce `TableExcelExportService` with functions for workbook creation and export (`normalizeExcelValue`, `createWorkbookFromRows`, `downloadWorkbook`).
- Add unit tests for `SelfServeVehicleStep` to test vehicle selection persistence, registration normalization, and emission behavior.
- Extend logic and tests for Self-Serve vehicle management with error handling, dynamic state updates, and step transitions (`MyWashStart.vue`).
- Integrate Excel export in `PaginatedList.vue` with user-defined transformations and error resilience.
This commit is contained in:
Jeppe Bundgaard
2026-03-18 12:05:40 +01:00
parent ad33cfedf8
commit 25f9d7c071
16 changed files with 761 additions and 75 deletions
@@ -4,6 +4,7 @@ import WhiteBoxCard from "@/components/displays/boxes/WhiteBoxCard.vue";
defineProps<{
visibleQuestions: Array<{ id: number; question: string; description?: string }>;
answers: Record<number, boolean | undefined>;
loadingQuestionId?: number | null;
}>();
const emit = defineEmits<{
@@ -14,7 +15,14 @@ const emit = defineEmits<{
<template>
<div data-testid="self-serve-question-cards">
<div v-for="question in visibleQuestions" :key="question.id" class="box mb-4" :data-testid="`self-serve-question-${question.id}`">
<WhiteBoxCard :hideHeader="true" :toggleable="false" :default-open="true" :force-state="true" :force-state-footer="true">
<WhiteBoxCard
:hideHeader="true"
:toggleable="false"
:default-open="true"
:force-state="true"
:force-state-footer="true"
:loading="loadingQuestionId === question.id"
>
<template #content>
<p class="title is-5">{{ question.question }}</p>
</template>
@@ -24,6 +32,7 @@ const emit = defineEmits<{
class="button is-fullwidth"
:class="answers[question.id] === true ? 'is-success' : 'is-light'"
:data-testid="`self-serve-question-${question.id}-yes`"
:disabled="loadingQuestionId === question.id"
@click="emit('answer-question', question.id, true)"
>
{{ $t("common.yes") }}
@@ -34,6 +43,7 @@ const emit = defineEmits<{
class="button is-fullwidth"
:class="answers[question.id] === false ? 'is-danger' : 'is-light'"
:data-testid="`self-serve-question-${question.id}-no`"
:disabled="loadingQuestionId === question.id"
@click="emit('answer-question', question.id, false)"
>
{{ $t("common.no") }}
@@ -6,6 +6,7 @@ defineProps<{
isLoading: boolean;
visibleQuestions: Array<any>;
answers: Record<number, boolean | undefined>;
loadingQuestionId?: number | null;
editAnswers: boolean;
showDebug: boolean;
conditions: Array<any>;
@@ -30,7 +31,7 @@ const emitAnswerQuestion = (questionId: number, value: boolean) => {
<template>
<div data-testid="self-serve-questions-step">
<div v-if="isLoading" class="has-text-centered p-6">
<div v-if="isLoading && visibleQuestions.length === 0" class="has-text-centered p-6">
<b-icon pack="fas" icon="spinner" custom-class="fa-pulse" size="is-large" />
<p>{{ $t("self_wash.loading_data") }}</p>
</div>
@@ -134,6 +135,7 @@ const emitAnswerQuestion = (questionId: number, value: boolean) => {
<SelfServeQuestionCards
:visibleQuestions="visibleQuestions"
:answers="answers"
:loading-question-id="loadingQuestionId"
@answer-question="emitAnswerQuestion"
/>
</div>
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { ref, watch } from "vue";
import { BAutocomplete, BField, BInput, BMessage } from "buefy";
import SelfServeVehicleTypeSelector from "@/components/displays/selfServe/SelfServeVehicleTypeSelector.vue";
defineProps<{
const props = defineProps<{
customerNumber: string | number | null;
showCustomerNumberInput: boolean;
registrationNumber: string | null;
@@ -14,6 +15,7 @@ defineProps<{
selectedVehicleDescription: string | null;
availableProductIds: number[];
vehicleTypes: Array<any>;
vehicleStepError?: string | null;
}>();
const emit = defineEmits<{
@@ -22,7 +24,39 @@ const emit = defineEmits<{
(e: "select-vehicle-type", selection: any): void;
}>();
const normalizeRegistration = (value: string | null | undefined) => (value || "").toUpperCase();
const extractRegistrationValue = (value: unknown): string => {
if (typeof value === "string" || typeof value === "number") {
return String(value);
}
if (value && typeof value === "object") {
const asObject = value as Record<string, unknown>;
const candidateKeys = ["registration_number", "reg", "value", "label"];
for (const key of candidateKeys) {
const candidate = asObject[key];
if (typeof candidate === "string" || typeof candidate === "number") {
return String(candidate);
}
}
}
return "";
};
const normalizeRegistration = (value: unknown) => extractRegistrationValue(value).trim().toUpperCase();
const typedRegistration = ref(normalizeRegistration(props.registrationNumber));
watch(() => props.registrationNumber, (newValue) => {
typedRegistration.value = normalizeRegistration(newValue);
});
const emitRegistration = (value: unknown) => {
const normalized = normalizeRegistration(value);
typedRegistration.value = normalized;
emit("update:registrationNumber", normalized);
};
</script>
<template>
@@ -38,7 +72,7 @@ const normalizeRegistration = (value: string | null | undefined) => (value || ""
</b-field>
<b-field :label="$t('self_wash.registration_number')">
<b-autocomplete
:model-value="registrationNumber || ''"
:model-value="typedRegistration"
data-testid="self-serve-registration"
:data="registrationOptions"
:placeholder="$t('self_wash.enter_registration_number')"
@@ -50,36 +84,39 @@ const normalizeRegistration = (value: string | null | undefined) => (value || ""
clearable
:loading="registrationOptions.length === 0 && isCustomerVehiclesLoading"
:selectable-header="true"
@select="emit('update:registrationNumber', normalizeRegistration(registrationNumber))"
@input="emit('update:registrationNumber', normalizeRegistration($event))"
@select-header="emit('update:registrationNumber', normalizeRegistration(registrationNumber))"
@select="emitRegistration($event)"
@typing="emitRegistration($event)"
@update:modelValue="emitRegistration($event)"
@select-header="emitRegistration(typedRegistration)"
>
<template #header>
<template v-if="hasMatchingVehicle">
{{ $t("self_wash.select_from_vehicles", { plate: registrationNumber }) }}
{{ $t("self_wash.select_from_vehicles", { plate: typedRegistration }) }}
</template>
<template v-else>
{{ $t("self_wash.add_as_new_vehicle", { plate: registrationNumber }) }}
{{ $t("self_wash.add_as_new_vehicle", { plate: typedRegistration }) }}
</template>
</template>
<template #empty>{{ $t("self_wash.no_vehicles_found") }}</template>
</b-autocomplete>
</b-field>
<b-message
v-if="props.vehicleStepError"
type="is-danger"
has-icon
:closable="false"
data-testid="self-serve-vehicle-step-error"
>
{{ props.vehicleStepError }}
</b-message>
<b-field :label="$t('self_wash.select_your_vehicle')">
<template v-if="availableProductIds.length > 0 && !selectedVehicleTypeId">
<template v-if="availableProductIds.length > 0">
<SelfServeVehicleTypeSelector
:selectedVehicleTypeId="selectedVehicleTypeId"
:restrictToProductIds="availableProductIds"
@selected="emit('select-vehicle-type', $event)"
/>
</template>
<template v-else-if="availableProductIds.length > 0 && selectedVehicleTypeId">
<div class="box has-background-light has-text-centered p-4" data-testid="self-serve-selected-vehicle-type">
<p class="title is-6 mb-2">{{ $t("self_wash.select_vehicle_type") }} {{ $t("common.selected") }}</p>
<p><strong>{{ selectedVehicleName }}</strong></p>
<p v-if="selectedVehicleDescription" class="is-size-7 mt-2">{{ selectedVehicleDescription }}</p>
</div>
</template>
<template v-else-if="vehicleTypes.length === 0">
<b-message type="is-info" :aria-close-label="$t('common.close')">
{{ $t("self_wash.loading_vehicle_types") }}
+185 -22
View File
@@ -3,6 +3,7 @@ import { ref, inject, provide } from 'vue';
import axios from "axios";
import {API_URL} from "@/config.js";
import { parseError, clearErrors } from "@/components/request/HandleGlobalError.vue";
import { exportRowsToExcel } from "@/services/TableExcelExportService.js";
export const PaginatedListKey = Symbol('PaginatedList');
@@ -36,6 +37,46 @@ export function usePaginatedList() {
/** Additional query parameters */
const additionalQueryParameters = ref({});
/** Excel export state */
const isExporting = ref(false);
const exportTransform = ref(null);
const resolveRequestLimit = (limit = null) => {
const parsedLimit = Number.parseInt(limit, 10);
if (Number.isFinite(parsedLimit) && parsedLimit > 0) {
return parsedLimit;
}
const parsedCurrentLimit = Number.parseInt(metaItemsPerPage.value, 10);
if (Number.isFinite(parsedCurrentLimit) && parsedCurrentLimit > 0) {
return parsedCurrentLimit;
}
return 100;
};
const buildRequestParams = ({ page = metaCurrentPage.value, limit = metaItemsPerPage.value } = {}) => {
const effectiveOrderBy = orderBy.value || 'id';
const effectiveOrderDirection = orderDirection.value || 'asc';
return {
page: Number.parseInt(page, 10) || 1,
limit: resolveRequestLimit(limit),
search: metaSearch.value,
filters: filter.value,
order: `${effectiveOrderBy}:${effectiveOrderDirection}`,
...additionalQueryParameters.value,
};
};
const buildRequestHeaders = (token) => {
return {
Authorization: `Bearer ${token}`,
// Add X-Customer-Number header if subuser has selected a grant
'X-Customer-Number': localStorage.getItem('selected_customer_number') || '',
};
};
/**
* Set the hide search field
* @param hide true or false
@@ -92,35 +133,34 @@ export function usePaginatedList() {
if (!token) {
return null;
}
axios.get(API_URL + endpoint.value, {
params: {
page: metaCurrentPage.value,
limit: metaItemsPerPage.value,
search: metaSearch.value,
filters: filter.value,
order: `${orderBy.value}:${orderDirection.value}`,
...additionalQueryParameters.value, // Add any additional query parameters, as the last parameters to override any existing parameters if needed
},
headers: {
Authorization: `Bearer ${token}`,
// Add X-Customer-Number header if subuser has selected a grant
'X-Customer-Number': localStorage.getItem('selected_customer_number') || ''
}
}) .then((response) => {
try {
const response = await axios.get(API_URL + endpoint.value, {
params: buildRequestParams(),
headers: buildRequestHeaders(token)
});
// Check if the search is the latest search
if (!isLatestSearch(tmp_search)) {
return;
return null;
}
setList(response.data.data);
setMeta(response.data.meta.pagination.page, response.data.meta.pagination.per_page, response.data.meta.pagination.total);
meta.value = response.data.meta;
setList(response?.data?.data || []);
setMeta(
response?.data?.meta?.pagination?.page || 1,
response?.data?.meta?.pagination?.per_page || resolveRequestLimit(),
response?.data?.meta?.pagination?.total || 0
);
meta.value = response?.data?.meta || null;
loadSwitch(false);
setLastUpdated();
}).catch((error) => {
return response;
} catch (error) {
parseError(error, 'paginatedGetRequest');
console.log(error);
loadSwitch(false);
});
return null;
}
};
/** The load list function */
@@ -263,6 +303,122 @@ export function usePaginatedList() {
return null;
};
const applyExportTransform = (rows, transform = null) => {
const sourceRows = Array.isArray(rows) ? rows : [];
const effectiveTransform = typeof transform === 'function'
? transform
: exportTransform.value;
if (typeof effectiveTransform !== 'function') {
return sourceRows;
}
const transformedRows = effectiveTransform([...sourceRows], {
endpoint: endpoint.value,
search: metaSearch.value,
filter: filter.value,
orderBy: orderBy.value,
orderDirection: orderDirection.value,
additionalQueryParameters: { ...additionalQueryParameters.value },
});
return Array.isArray(transformedRows) ? transformedRows : sourceRows;
};
const setExportTransform = (transform) => {
exportTransform.value = typeof transform === 'function' ? transform : null;
};
const clearExportTransform = () => {
exportTransform.value = null;
};
const fetchPageForExport = async (page = 1, limit = null) => {
const token = localStorage.getItem('token');
if (!token || !endpoint.value) {
return {
rows: [],
page: 1,
perPage: resolveRequestLimit(limit),
total: 0,
totalPages: 1,
};
}
const response = await axios.get(API_URL + endpoint.value, {
params: buildRequestParams({ page, limit }),
headers: buildRequestHeaders(token),
});
const rows = Array.isArray(response?.data?.data) ? response.data.data : [];
const pagination = response?.data?.meta?.pagination || {};
const currentPage = Number.parseInt(pagination.page, 10) || Number.parseInt(page, 10) || 1;
const perPage = Number.parseInt(pagination.per_page, 10) || resolveRequestLimit(limit);
const total = Number.parseInt(pagination.total, 10);
const totalItems = Number.isFinite(total) ? total : rows.length;
const totalPages = Math.max(1, Math.ceil(totalItems / Math.max(1, perPage)));
return {
response,
rows,
page: currentPage,
perPage,
total: totalItems,
totalPages,
};
};
const fetchAllPagesForExport = async ({ itemsPerPage = null, transform = null } = {}) => {
const effectiveLimit = resolveRequestLimit(itemsPerPage);
const firstPage = await fetchPageForExport(1, effectiveLimit);
const rows = [...firstPage.rows];
for (let page = 2; page <= firstPage.totalPages; page += 1) {
const pageResult = await fetchPageForExport(page, effectiveLimit);
rows.push(...pageResult.rows);
}
return applyExportTransform(rows, transform);
};
const exportToExcel = async ({
filename = null,
sheetName = null,
transform = null,
rows = null,
itemsPerPage = null,
} = {}) => {
if (isExporting.value) {
return false;
}
isExporting.value = true;
try {
const endpointSlug = String(endpoint.value || 'table')
.replace(/^\//, '')
.replace(/[\\/]+/g, '-')
.replace(/\s+/g, '-')
.toLowerCase() || 'table';
const exportRows = Array.isArray(rows)
? applyExportTransform(rows, transform)
: await fetchAllPagesForExport({ itemsPerPage, transform });
exportRowsToExcel(exportRows, {
filename: filename || `${endpointSlug}-${new Date().toISOString().slice(0, 10)}.xlsx`,
sheetName: sheetName || endpointSlug,
});
return true;
} catch (error) {
parseError(error, 'paginatedExportToExcel');
console.log(error);
return false;
} finally {
isExporting.value = false;
}
};
return {
list,
metaCurrentPage,
@@ -277,8 +433,10 @@ export function usePaginatedList() {
orderDirection,
hideSearchField,
isLoading,
isExporting,
latestSearch,
additionalQueryParameters,
exportTransform,
setHideSearchField,
setOrder,
setEndpoint,
@@ -298,7 +456,12 @@ export function usePaginatedList() {
clearAdditionalQueryParameters,
paginatedGetRequest,
loadList,
getFilter
getFilter,
fetchPageForExport,
fetchAllPagesForExport,
setExportTransform,
clearExportTransform,
exportToExcel
};
}
@@ -62,7 +62,7 @@ const visibleChildren = (item: any) => {
<template>
<div class="section pl-3 pr-1 pt-0">
<b-menu ref="menuList">
<div class="mb-4">
<div v-if="SessionUser.canAccessSuperUser()" class="mb-4">
<NavigationMenuGlobalSearch />
</div>
<b-menu-list label="">
@@ -2312,7 +2312,7 @@ const asJson = (v: unknown) => {
};
</script>
<template>
<div class="system-search-launcher">
<div v-if="SessionUser.canAccessSuperUser()" class="system-search-launcher">
<p class="menu-label mb-2">{{ t('global_search.launcher.label') }}</p>
<b-button expanded type="is-link" icon-left="search" icon-pack="fas" @click="isModalOpen = true">{{ t('global.search') }}</b-button>
<p v-if="meta" class="is-size-7 has-text-grey mt-2">{{ t('global_search.launcher.last_search', { query: meta.query, total: meta.total }) }}</p>
+52 -6
View File
@@ -56,6 +56,41 @@ const buildAnswersMap = (questions) => questions.reduce((accumulator, question)
return accumulator;
}, {});
const mergeQuestionSets = (existingQuestions, incomingQuestions) => {
if (!Array.isArray(existingQuestions) || existingQuestions.length === 0) {
return [...incomingQuestions].sort((a, b) => a.order_priority - b.order_priority);
}
if (!Array.isArray(incomingQuestions) || incomingQuestions.length === 0) {
return [...existingQuestions].sort((a, b) => a.order_priority - b.order_priority);
}
const incomingMap = incomingQuestions.reduce((accumulator, question) => {
accumulator[question.id] = question;
return accumulator;
}, {});
const merged = existingQuestions.map((question) => {
if (!incomingMap[question.id]) {
return question;
}
return {
...question,
...incomingMap[question.id],
};
});
incomingQuestions.forEach((question) => {
const exists = merged.some((entry) => entry.id === question.id);
if (!exists) {
merged.push(question);
}
});
return merged.sort((a, b) => a.order_priority - b.order_priority);
};
export function useSelfServeLogic() {
const loading = ref(false);
const preview = ref(null);
@@ -72,6 +107,7 @@ export function useSelfServeLogic() {
const answers = ref({});
const completedTasks = ref({});
const allowedServices = ref([]);
const lastPreviewContextKey = ref(null);
const isImage = (attachment) => {
const filename = attachment?.content?.other || "";
@@ -126,7 +162,8 @@ export function useSelfServeLogic() {
}));
};
const applyPreviewData = async (previewData) => {
const applyPreviewData = async (previewData, options = {}) => {
const { mergeQuestions = false } = options;
preview.value = previewData || null;
lane.value = previewData?.lane || lane.value;
machineType.value = previewData?.machine_type || machineType.value;
@@ -137,8 +174,12 @@ export function useSelfServeLogic() {
? previewData.questions.map(normalizeQuestion).sort((a, b) => a.order_priority - b.order_priority)
: [];
questions.value = normalizedQuestions;
answers.value = buildAnswersMap(normalizedQuestions);
const nextQuestions = mergeQuestions
? mergeQuestionSets(questions.value, normalizedQuestions)
: normalizedQuestions;
questions.value = nextQuestions;
answers.value = buildAnswersMap(nextQuestions);
conditions.value = Array.isArray(previewData?.conditions)
? previewData.conditions.map(normalizeCondition)
@@ -178,8 +219,9 @@ export function useSelfServeLogic() {
};
});
questions.value = normalizedQuestions;
answers.value = buildAnswersMap(normalizedQuestions);
const nextQuestions = mergeQuestionSets(questions.value, normalizedQuestions);
questions.value = nextQuestions;
answers.value = buildAnswersMap(nextQuestions);
}
if (Array.isArray(summaryData?.conditions)) {
@@ -251,14 +293,18 @@ export function useSelfServeLogic() {
tasks.value = [];
allowedServices.value = [];
answers.value = {};
lastPreviewContextKey.value = null;
return null;
}
loading.value = true;
try {
const normalizedReg = reg.trim().toUpperCase();
const contextKey = `${parseInt(laneId)}:${normalizedReg}`;
const shouldMergeQuestions = lastPreviewContextKey.value === contextKey;
const previewData = await SessionUser.objects.self_serve_vehicle_conditions.get.previewAllowed(laneId, normalizedReg);
await applyPreviewData(previewData);
await applyPreviewData(previewData, { mergeQuestions: shouldMergeQuestions });
lastPreviewContextKey.value = contextKey;
if (previewData?.session?.id) {
await fetchWashSummary({ session_id: previewData.session.id }, false);
+11 -3
View File
@@ -40,13 +40,21 @@ export function useWashFlowState(options) {
return currentStep.value === steps.VEHICLE;
}
const customerNumberValid = !customerNumberInput.value || !!parseInt(customerNumberInput.value);
const selectedVehicleTypeId = vehicleTypeSelect.value;
const allowedProductIds = new Set((availableProductIds.value || []).map((productId) => String(productId)));
const hasAllowedVehicleType = selectedVehicleTypeId !== null
&& selectedVehicleTypeId !== undefined
&& allowedProductIds.has(String(selectedVehicleTypeId));
const customerNumberValue = customerNumberInput.value;
const customerNumberValid = customerNumberValue === null
|| customerNumberValue === undefined
|| String(customerNumberValue).trim() !== "";
const licensePlateValid = !!(licensePlateInput.value && licensePlateInput.value.trim() !== "");
if (!vehicleTypeSelect.value || !availableProductIds.value.includes(parseInt(vehicleTypeSelect.value))) {
if (!hasAllowedVehicleType) {
return false;
}
return customerNumberValid && licensePlateValid && !!vehicleTypeSelect.value;
return customerNumberValid && licensePlateValid;
},
[steps.QUESTIONS]: () => {
if (washInProgress.value && currentStep.value >= steps.TASKS) {
+129
View File
@@ -0,0 +1,129 @@
import * as XLSX from 'xlsx';
const INVALID_SHEET_NAME_REGEX = /[:\\/?*\[\]]/g;
const INVALID_FILENAME_CHARS_REGEX = /[<>:"/\\|?*\x00-\x1F]/g;
export const normalizeExcelValue = (value) => {
if (value === null || value === undefined) {
return '';
}
if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? '' : value.toISOString();
}
if (Array.isArray(value)) {
return JSON.stringify(value);
}
if (typeof value === 'object') {
return JSON.stringify(value);
}
return value;
};
const normalizeSourceRows = (rows = []) => {
if (!Array.isArray(rows)) {
return [];
}
return rows.map((row) => {
if (row && typeof row === 'object' && !Array.isArray(row)) {
return row;
}
if (Array.isArray(row)) {
return row.reduce((accumulator, value, index) => {
accumulator[String(index)] = value;
return accumulator;
}, {});
}
return { value: row };
});
};
export const collectExcelColumns = (rows = []) => {
const columns = [];
const seen = new Set();
normalizeSourceRows(rows).forEach((row) => {
Object.keys(row).forEach((key) => {
if (!seen.has(key)) {
seen.add(key);
columns.push(key);
}
});
});
return columns;
};
export const normalizeRowsForExcel = (rows = []) => {
const normalizedSourceRows = normalizeSourceRows(rows);
const columns = collectExcelColumns(normalizedSourceRows);
const normalizedRows = normalizedSourceRows.map((row) => {
const normalized = {};
columns.forEach((column) => {
normalized[column] = normalizeExcelValue(row[column]);
});
return normalized;
});
return {
columns,
rows: normalizedRows,
};
};
export const sanitizeSheetName = (sheetName = 'Data') => {
const baseName = String(sheetName || 'Data').trim();
const withoutInvalidChars = baseName.replace(INVALID_SHEET_NAME_REGEX, '_');
const trimmed = withoutInvalidChars.slice(0, 31);
return trimmed.length ? trimmed : 'Data';
};
export const ensureExcelFilename = (filename = 'export.xlsx') => {
const base = String(filename || 'export.xlsx')
.trim()
.replace(INVALID_FILENAME_CHARS_REGEX, '-');
if (!base.length) {
return 'export.xlsx';
}
return base.toLowerCase().endsWith('.xlsx') ? base : `${base}.xlsx`;
};
export const createWorkbookFromRows = (rows = [], options = {}) => {
const { columns, rows: normalizedRows } = normalizeRowsForExcel(rows);
const sheetName = sanitizeSheetName(options.sheetName || 'Data');
const worksheet = columns.length
? XLSX.utils.json_to_sheet(normalizedRows, { header: columns, skipHeader: false })
: XLSX.utils.aoa_to_sheet([]);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
return {
workbook,
sheetName,
columns,
rows: normalizedRows,
};
};
export const downloadWorkbook = (workbook, filename = 'export.xlsx') => {
const safeFilename = ensureExcelFilename(filename);
XLSX.writeFile(workbook, safeFilename, { compression: true });
return safeFilename;
};
export const exportRowsToExcel = (rows = [], options = {}) => {
const sourceRows = Array.isArray(rows) ? rows : [];
const transformedRows = typeof options.transform === 'function'
? options.transform([...sourceRows])
: sourceRows;
const { workbook } = createWorkbookFromRows(
Array.isArray(transformedRows) ? transformedRows : [],
{ sheetName: options.sheetName }
);
return downloadWorkbook(workbook, options.filename || 'export.xlsx');
};
@@ -46,6 +46,8 @@ const showDebug = ref(false);
const currentGuidedWashStep = ref(0);
const currentStep = ref(0);
const hideDynamicImage = ref(false);
const vehicleStepError = ref<string | null>(null);
const loadingQuestionId = ref<number | null>(null);
const {
guestDepartments,
@@ -234,6 +236,31 @@ const displayedDynamicImageUrl = computed(() => (
hideDynamicImage.value ? null : dynamicImageUrl.value
));
const normalizeLicensePlate = (value: string | null) => (value || "").trim().toUpperCase();
const getNumericCustomerNumber = () => {
const candidate = customerNumberInput.value || SessionUser.user.customer_number.value || null;
if (candidate === null || candidate === undefined || String(candidate).trim() === "") {
return null;
}
const parsed = parseInt(String(candidate), 10);
return Number.isNaN(parsed) ? null : parsed;
};
const extractErrorMessage = (error: any, fallback: string) => {
if (error?.response?.data?.data?.message) {
return error.response.data.data.message;
}
if (error?.response?.data?.error) {
return error.response.data.error;
}
if (error?.message) {
return error.message;
}
return fallback;
};
watch(dynamicImageUrl, () => {
hideDynamicImage.value = false;
});
@@ -272,6 +299,45 @@ const doesUserVehicleExist = (licensePlate: string | null) => {
return customerVehicles.value.some((vehicle) => vehicle.reg.toUpperCase() === licensePlate.trim().toUpperCase());
};
const addVehicleIfMissing = async () => {
const normalizedPlate = normalizeLicensePlate(licensePlateInput.value);
if (!normalizedPlate || !vehicleTypeSelect.value) {
return false;
}
if (doesUserVehicleExist(normalizedPlate)) {
return true;
}
try {
const customerNumber = getNumericCustomerNumber();
if (customerNumber !== null) {
await SessionUser.objects.vehicles.add(
vehicleTypeSelect.value,
normalizedPlate,
false,
customerNumber
);
} else {
await SessionUser.request("/user/vehicles", "POST", {
reg: normalizedPlate,
type: String(vehicleTypeSelect.value),
notes: null,
});
}
await fetchCustomerVehicles();
return true;
} catch (error) {
console.error("Error adding vehicle during self-serve step:", error);
vehicleStepError.value = extractErrorMessage(
error,
"Kunne ikke tilfoeje koeretoejet. Kontroller registreringsnummeret og proev igen."
);
return false;
}
};
const fetchCustomerVehicles = async () => {
isCustomerVehiclesLoading.value = true;
try {
@@ -319,9 +385,33 @@ const onSelectVehicleType = (selection: VehicleTypeTemplate) => {
return;
}
vehicleStepError.value = null;
vehicleTypeSelect.value = selection.id;
};
const onUpdateCustomerNumber = (value: string) => {
vehicleStepError.value = null;
customerNumberInput.value = value;
};
const onUpdateRegistrationNumber = (value: string) => {
vehicleStepError.value = null;
licensePlateInput.value = normalizeLicensePlate(value);
};
const onVehicleStepNext = async () => {
vehicleStepError.value = null;
licensePlateInput.value = normalizeLicensePlate(licensePlateInput.value);
const addedOrExisting = await addVehicleIfMissing();
if (!addedOrExisting) {
return;
}
await fetchSelfServeData();
currentStep.value = steps.QUESTIONS;
};
const onToggleTask = (taskId: number, value: boolean) => {
completedTasks.value = {
...completedTasks.value,
@@ -330,9 +420,15 @@ const onToggleTask = (taskId: number, value: boolean) => {
};
const submitQuestionAnswer = async (questionId: number, value: boolean) => {
if (loadingQuestionId.value !== null) {
return;
}
loadingQuestionId.value = questionId;
answerQuestion(questionId, value);
if (!nearestDepartment.value || !radioLaneOption.value || !licensePlateInput.value?.trim()) {
loadingQuestionId.value = null;
return;
}
@@ -347,6 +443,8 @@ const submitQuestionAnswer = async (questionId: number, value: boolean) => {
});
} catch (error) {
console.error("Error synchronizing self-serve answer:", error);
} finally {
loadingQuestionId.value = null;
}
};
@@ -363,6 +461,7 @@ const onCloseCompleted = () => {
licensePlateInput.value = "";
answers.value = {};
editAnswers.value = false;
vehicleStepError.value = null;
currentStep.value = steps.VEHICLE;
clearProgress();
saveProgress("onCloseCompleted");
@@ -501,23 +600,6 @@ watch(() => forceNearestDepartmentEvaluationId.value, () => {
saveProgress("forceNearestDepartmentEvaluationId");
});
watch(() => allVisibleQuestionsAnswered.value, (newValue) => {
if (
newValue
&& !editAnswers.value
&& !washInProgress.value
&& activeTasks.value.length === 0
&& currentStep.value === steps.QUESTIONS
&& !isRestoring.value
) {
onStartWash(
radioLaneOption.value,
licensePlateInput.value,
customerNumberInput.value,
targetStepForStart()
);
}
});
</script>
<template>
@@ -574,8 +656,9 @@ watch(() => allVisibleQuestionsAnswered.value, (newValue) => {
:selected-vehicle-description="selectedVehicleTypeDescription"
:available-product-ids="availableProductIds"
:vehicle-types="vehicleTypes"
@update:customer-number="customerNumberInput = $event"
@update:registration-number="licensePlateInput = $event"
:vehicle-step-error="vehicleStepError"
@update:customer-number="onUpdateCustomerNumber"
@update:registration-number="onUpdateRegistrationNumber"
@select-vehicle-type="onSelectVehicleType"
/>
</b-step-item>
@@ -592,6 +675,7 @@ watch(() => allVisibleQuestionsAnswered.value, (newValue) => {
:is-loading="isLoadingSelfServeData"
:visible-questions="visibleQuestions"
:answers="answers"
:loading-question-id="loadingQuestionId"
:edit-answers="editAnswers"
:show-debug="showDebug"
:conditions="conditions"
@@ -703,7 +787,7 @@ watch(() => allVisibleQuestionsAnswered.value, (newValue) => {
icon-right="arrow-right"
data-testid="self-serve-nav-next"
:disabled="isNextButtonDisabled()"
@click.prevent="currentStep = steps.QUESTIONS"
@click.prevent="onVehicleStepNext"
>
{{ $t("common.next") }}
</b-button>
+2 -2
View File
@@ -45,7 +45,7 @@ async function fillRegistration(page, value) {
async function selectVehicleType(page, vehicleTypeId = 2) {
await page.getByTestId(`self-serve-vehicle-type-${vehicleTypeId}`).click();
await expect(page.getByTestId("self-serve-selected-vehicle-type")).toContainText(/Truck|Van|Car/);
await expect(page.getByTestId("self-serve-vehicle-selector-description")).toContainText(/Truck|Van|Car/);
}
function buildSavedProgress(overrides = {}) {
@@ -315,7 +315,7 @@ test.describe("Self-serve wash", () => {
await fillRegistration(page, "ab12345");
await selectVehicleType(page, 2);
await expect(page.getByTestId("self-serve-nav-next")).toBeVisible();
await expect(page.getByTestId("self-serve-selected-vehicle-type")).toContainText("Truck");
await expect(page.getByTestId("self-serve-vehicle-selector-description")).toContainText("Truck");
});
test("admin preview modal reuses shared question/task rendering", async ({ page }) => {
+55 -1
View File
@@ -68,6 +68,8 @@ const mocks = vi.hoisted(() => {
setShowFooterInContent: vi.fn(),
fetchCustomerVehicles: vi.fn(),
fetchVehicleTypeOptions: vi.fn(),
addVehicle: vi.fn(),
sessionRequest: vi.fn(),
};
});
@@ -77,7 +79,10 @@ vi.mock("@/components/session/token/SessionUser.vue", () => ({
customer_number: { value: 12345 },
},
canAccessSuperUser: () => true,
request: vi.fn(),
request: vi.fn(async (...args) => {
mocks.sessionRequest(...args);
return { status: 200, data: { data: {} } };
}),
functions: {
contact: {
onClickCallPhoneNumber: vi.fn(),
@@ -85,6 +90,10 @@ vi.mock("@/components/session/token/SessionUser.vue", () => ({
},
objects: {
vehicles: {
add: vi.fn(async (...args) => {
mocks.addVehicle(...args);
return { status: 200, data: { data: {} } };
}),
get: {
all: vi.fn(async () => {
mocks.fetchCustomerVehicles();
@@ -259,6 +268,7 @@ const stubComponents = {
template: `
<div data-testid="vehicle-step-stub">
<button data-testid="emit-registration" @click="$emit('update:registration-number', 'AB12345')">registration</button>
<button data-testid="emit-registration-unknown" @click="$emit('update:registration-number', 'ZZ99999')">registration-unknown</button>
<button data-testid="emit-vehicle-type" @click="$emit('select-vehicle-type', { id: 2, name: 'Truck' })">vehicle</button>
</div>
`,
@@ -313,6 +323,8 @@ describe("MyWashStart", () => {
mocks.syncVehicleAnswer.mockClear();
mocks.answerQuestion.mockClear();
mocks.setShowFooterInContent.mockClear();
mocks.addVehicle.mockClear();
mocks.sessionRequest.mockClear();
});
it("loads initial wash context and tears down refresh state on unmount", async () => {
@@ -388,4 +400,46 @@ describe("MyWashStart", () => {
expect(wrapper.find('[data-testid="self-serve-disabled-warning"]').exists()).toBe(true);
expect(wrapper.text()).not.toContain("self_wash.loading_data");
});
it("adds an unknown registration as customer vehicle before continuing from vehicle step", async () => {
const wrapper = mountWithApp(MyWashStart, {
global: {
stubs: stubComponents,
},
});
await flushPromises();
await wrapper.get('[data-testid="emit-lane"]').trigger("click");
await wrapper.get('[data-testid="emit-registration-unknown"]').trigger("click");
await wrapper.get('[data-testid="emit-vehicle-type"]').trigger("click");
await nextTick();
await wrapper.get('[data-testid="self-serve-nav-next"]').trigger("click");
await flushPromises();
expect(mocks.addVehicle).toHaveBeenCalledWith(2, "ZZ99999", false, 12345);
expect(mocks.fetchCustomerVehicles.mock.calls.length).toBeGreaterThan(1);
expect(mocks.fetchSelfServeDataInternal).toHaveBeenCalledWith(1, 2, 7, "ZZ99999");
});
it("does not auto-start wash when only the first visible question is answered", async () => {
const wrapper = mountWithApp(MyWashStart, {
global: {
stubs: stubComponents,
},
});
await flushPromises();
await wrapper.get('[data-testid="emit-lane"]').trigger("click");
await wrapper.get('[data-testid="emit-registration"]').trigger("click");
await wrapper.get('[data-testid="emit-vehicle-type"]').trigger("click");
await nextTick();
await wrapper.get('[data-testid="self-serve-nav-next"]').trigger("click");
await flushPromises();
await wrapper.get('[data-testid="emit-answer"]').trigger("click");
await flushPromises();
expect(mocks.onStartWash).not.toHaveBeenCalled();
});
});
+17 -1
View File
@@ -6,6 +6,7 @@ import { mountWithApp } from "./helpers/mountWithApp.js";
describe("SelfServeQuestionsStep", () => {
const baseProps = {
isLoading: false,
loadingQuestionId: null,
visibleQuestions: [
{ id: 11, question: "Is the trailer closed?" },
],
@@ -41,11 +42,12 @@ describe("SelfServeQuestionsStep", () => {
expect(wrapper.emitted("answer-question")).toEqual([[11, false]]);
});
it("shows the loading state while data is being fetched", () => {
it("shows the full loading state only when no questions are available yet", () => {
const wrapper = mountWithApp(SelfServeQuestionsStep, {
props: {
...baseProps,
isLoading: true,
visibleQuestions: [],
showDebug: false,
},
});
@@ -53,4 +55,18 @@ describe("SelfServeQuestionsStep", () => {
expect(wrapper.text()).toContain("self_wash.loading_data");
expect(wrapper.find('[data-testid="self-serve-question-cards"]').exists()).toBe(false);
});
it("keeps the question list visible and only locks the question being synced", () => {
const wrapper = mountWithApp(SelfServeQuestionsStep, {
props: {
...baseProps,
isLoading: true,
loadingQuestionId: 11,
},
});
expect(wrapper.find('[data-testid="self-serve-question-cards"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="self-serve-question-11-yes"]').attributes("disabled")).toBeDefined();
expect(wrapper.find('[data-testid="self-serve-question-11-no"]').attributes("disabled")).toBeDefined();
});
});
+107
View File
@@ -0,0 +1,107 @@
// @vitest-environment jsdom
import { describe, expect, it, vi } from "vitest";
import { mount } from "@vue/test-utils";
import SelfServeVehicleStep from "@/components/displays/selfServe/SelfServeVehicleStep.vue";
vi.mock("@/components/displays/selfServe/SelfServeVehicleTypeSelector.vue", () => ({
default: {
emits: ["selected"],
template: "<button data-testid='selector-stub' @click=\"$emit('selected', { id: 3, name: 'Car' })\">selector</button>",
},
}));
const AutocompleteStub = {
emits: ["select", "typing", "update:modelValue", "select-header"],
template: `
<div>
<button data-testid="auto-update-object" @click="$emit('update:modelValue', { registration_number: 'ec21235' })">object</button>
<button data-testid="auto-select-string" @click="$emit('select', 'ab12345')">select</button>
<button data-testid="auto-select-header" @click="$emit('select-header', $event)">header</button>
<slot name="header" />
<slot name="empty" />
</div>
`,
};
describe("SelfServeVehicleStep", () => {
it("keeps vehicle selector available after an initial vehicle type has already been selected", async () => {
const wrapper = mount(SelfServeVehicleStep, {
props: {
customerNumber: "12345",
showCustomerNumberInput: false,
registrationNumber: "AB12345",
registrationOptions: ["AB12345"],
isCustomerVehiclesLoading: false,
hasMatchingVehicle: true,
selectedVehicleTypeId: 2,
selectedVehicleName: "Truck",
selectedVehicleDescription: "Large truck",
availableProductIds: [2, 3],
vehicleTypes: [{ id: 2 }, { id: 3 }],
},
global: {
mocks: {
$t: (value) => value,
},
stubs: {
BField: {
template: "<div><slot /></div>",
},
BAutocomplete: AutocompleteStub,
BInput: true,
BMessage: {
template: "<div><slot /></div>",
},
},
},
});
expect(wrapper.find('[data-testid="selector-stub"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="self-serve-selected-vehicle-type"]').exists()).toBe(false);
await wrapper.get('[data-testid="selector-stub"]').trigger("click");
expect(wrapper.emitted("select-vehicle-type")).toHaveLength(1);
expect(wrapper.emitted("select-vehicle-type")?.[0]?.[0]).toMatchObject({ id: 3, name: "Car" });
});
it("normalizes autocomplete payloads and always emits uppercase registration", async () => {
const wrapper = mount(SelfServeVehicleStep, {
props: {
customerNumber: "12345",
showCustomerNumberInput: false,
registrationNumber: "",
registrationOptions: ["AB12345"],
isCustomerVehiclesLoading: false,
hasMatchingVehicle: false,
selectedVehicleTypeId: 2,
selectedVehicleName: "Truck",
selectedVehicleDescription: "Large truck",
availableProductIds: [2],
vehicleTypes: [{ id: 2 }],
},
global: {
mocks: {
$t: (value) => value,
},
stubs: {
BField: {
template: "<div><slot /></div>",
},
BAutocomplete: AutocompleteStub,
BInput: true,
BMessage: {
template: "<div><slot /></div>",
},
},
},
});
await wrapper.get('[data-testid="auto-update-object"]').trigger("click");
await wrapper.get('[data-testid="auto-select-string"]').trigger("click");
const emitted = wrapper.emitted("update:registrationNumber") || [];
expect(emitted[0]?.[0]).toBe("EC21235");
expect(emitted[1]?.[0]).toBe("AB12345");
});
});
+23
View File
@@ -129,6 +129,27 @@ describe("useSelfServeLogic", () => {
});
it("synchronizes answers and refreshes preview/summary state", async () => {
mocks.previewAllowed.mockResolvedValueOnce({
allowed: true,
session: { id: 44, status: "IN_PROGRESS" },
questions: [
{ id: 2, question: "Doors closed?", answer: null, order_priority: 1 },
{ id: 3, question: "Windows closed?", answer: null, order_priority: 2 },
],
tasks: [],
conditions: [],
rules: [],
});
mocks.washSummary.mockResolvedValueOnce({
session: { id: 44, status: "IN_PROGRESS" },
questions: [
{ id: 2, question: "Doors closed?", answer: null, order_priority: 1 },
{ id: 3, question: "Windows closed?", answer: null, order_priority: 2 },
],
tasks: [],
events: [],
});
mocks.add.mockResolvedValue({
data: {
data: {
@@ -164,6 +185,7 @@ describe("useSelfServeLogic", () => {
});
const logic = useSelfServeLogic();
await logic.fetchSelfServeData(3, null, 7, "cd67890");
await logic.syncVehicleAnswer({
departmentId: 3,
@@ -177,5 +199,6 @@ describe("useSelfServeLogic", () => {
expect(mocks.add).toHaveBeenCalledWith(3, 7, 12345, "CD67890", 2, true);
expect(mocks.previewAllowed).toHaveBeenCalledWith(7, "CD67890");
expect(logic.answers.value[2]).toBe(true);
expect(logic.visibleQuestions.value.map((question) => question.id)).toEqual([2, 3]);
});
});
+7
View File
@@ -42,6 +42,13 @@ describe("useWashFlowState", () => {
expect(flow.clickableSteps[WASH_STEPS.VEHICLE]()).toBe(true);
state.customerNumberInput.value = "EC21235";
expect(flow.clickableSteps[WASH_STEPS.VEHICLE]()).toBe(true);
state.availableProductIds.value = ["3", "4"];
state.vehicleTypeSelect.value = 3;
expect(flow.clickableSteps[WASH_STEPS.VEHICLE]()).toBe(true);
state.vehicleTypeSelect.value = 9;
expect(flow.clickableSteps[WASH_STEPS.VEHICLE]()).toBe(false);
});