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:
@@ -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") }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user