Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26cc166282 | ||
|
|
30e878979c | ||
|
|
0f335e0984 | ||
|
|
02c31c8bb0 | ||
|
|
6c0278c6e9 | ||
|
|
41e0320ce1 | ||
|
|
d97b02bace | ||
|
|
11bbe953f3 | ||
|
|
ecdd895a5a | ||
|
|
07bb815754 | ||
|
|
2a64fcdf19 | ||
|
|
f8b7bda74a | ||
|
|
5a0fc5f36b | ||
|
|
9e378b32ac |
@@ -51,6 +51,7 @@ type LPRResponse = {
|
||||
};
|
||||
|
||||
const latestLPRResponse = ref<LPRResponse | null>(null);
|
||||
const isLPRRequestInFlight = ref(false);
|
||||
const LPR_IMAGE_MAX_WIDTH = 1280;
|
||||
const LPR_IMAGE_MAX_HEIGHT = 720;
|
||||
const LPR_IMAGE_JPEG_QUALITY = 0.72;
|
||||
@@ -116,6 +117,9 @@ const handleLPRResult = () => {
|
||||
};
|
||||
|
||||
const parseImage = async (image: string) => {
|
||||
if (isLPRRequestInFlight.value) {
|
||||
return;
|
||||
}
|
||||
// Check if the time since the last successful parse is enough
|
||||
if (!camera.hasDelayAfterSuccessPassed()) {
|
||||
return;
|
||||
@@ -126,31 +130,35 @@ const parseImage = async (image: string) => {
|
||||
}
|
||||
lastParsedImage.value = image; // Update the last parsed image
|
||||
camera.setLatestImage(image); // Update the latest image in the camera object
|
||||
// Function to parse the image data
|
||||
const compressedImage = await compressImageForLPR(image);
|
||||
SessionUser.request("/modules/scanner/lpr", "POST", {
|
||||
base64_image: compressedImage,
|
||||
})
|
||||
.then((response) => {
|
||||
if (debug_mode.value) {
|
||||
debug_request_results.value.push(response);
|
||||
}
|
||||
// If the response is not successful, stop here.
|
||||
if (!response.data.success) {
|
||||
return;
|
||||
}
|
||||
latestLPRResponse.value = response.data.data as LPRResponse;
|
||||
// Set the last successful capture time
|
||||
camera.setLastSuccess();
|
||||
// Handle parsed result.
|
||||
handleLPRResult();
|
||||
})
|
||||
.catch((error) => {
|
||||
if (debug_mode.value) {
|
||||
debug_request_results.value.push(error);
|
||||
}
|
||||
//console.error("Error parsing image:", error);
|
||||
isLPRRequestInFlight.value = true;
|
||||
|
||||
try {
|
||||
// Function to parse the image data
|
||||
const compressedImage = await compressImageForLPR(image);
|
||||
const response = await SessionUser.request("/modules/scanner/lpr", "POST", {
|
||||
base64_image: compressedImage,
|
||||
});
|
||||
|
||||
if (debug_mode.value) {
|
||||
debug_request_results.value.push(response);
|
||||
}
|
||||
// If the response is not successful, stop here.
|
||||
if (!response.data.success) {
|
||||
return;
|
||||
}
|
||||
latestLPRResponse.value = response.data.data as LPRResponse;
|
||||
// Set the last successful capture time
|
||||
camera.setLastSuccess();
|
||||
// Handle parsed result.
|
||||
handleLPRResult();
|
||||
} catch (error) {
|
||||
if (debug_mode.value) {
|
||||
debug_request_results.value.push(error);
|
||||
}
|
||||
//console.error("Error parsing image:", error);
|
||||
} finally {
|
||||
isLPRRequestInFlight.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
watch(manualInput, (newValue) => {
|
||||
|
||||
@@ -106,8 +106,7 @@ const onDynamicImageError = () => {
|
||||
/>
|
||||
</div>
|
||||
<div v-show="allVisibleQuestionsAnswered && !editAnswers" class="notification is-info is-light mb-4">
|
||||
<h1 class="title has-text-centered mb-2" v-if="activeTasks.length > 0">{{ $t("self_wash.start_machine") }}</h1>
|
||||
<h1 class="title has-text-centered mb-2" v-else>{{ $t("self_wash.questions_answered") }}</h1>
|
||||
<h1 class="title has-text-centered mb-2">{{ $t("self_wash.start_machine") }}</h1>
|
||||
<SelfServeTaskList
|
||||
:tasks="activeTasks"
|
||||
:completedTasks="completedTasks"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import LocalDataResetDialog from "@/components/global/LocalDataResetDialog.vue";
|
||||
import { releaseUpdateState, shortReleaseCommit } from "@/services/releaseUpdate.js";
|
||||
import { forceFrontendUpdateAndClearLocal } from "@/services/frontendMaintenance.js";
|
||||
|
||||
@@ -10,6 +11,7 @@ const CLOSE_EVENT = "frontend-maintenance-menu:close";
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
const isOpen = ref(false);
|
||||
const isBusy = ref(false);
|
||||
const isClearConfirmationOpen = ref(false);
|
||||
const shiftPresses = ref([]);
|
||||
|
||||
const currentVersion = computed(() => shortReleaseCommit(releaseUpdateState.currentCommit));
|
||||
@@ -18,6 +20,7 @@ const latestVersion = computed(() => shortReleaseCommit(releaseUpdateState.lates
|
||||
const closeMenu = () => {
|
||||
if (!isBusy.value) {
|
||||
isOpen.value = false;
|
||||
isClearConfirmationOpen.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -34,8 +37,18 @@ const handleKeydown = (event) => {
|
||||
}
|
||||
};
|
||||
|
||||
const openClearConfirmation = () => {
|
||||
if (!isBusy.value) {
|
||||
isClearConfirmationOpen.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const closeClearConfirmation = () => {
|
||||
isClearConfirmationOpen.value = false;
|
||||
};
|
||||
|
||||
const forceUpdateAndClearLocal = async () => {
|
||||
if (isBusy.value || !window.confirm(t("maintenance_menu.confirm_clear_local"))) {
|
||||
if (isBusy.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -84,7 +97,7 @@ onBeforeUnmount(() => {
|
||||
class="frontend-maintenance-menu__danger"
|
||||
data-testid="frontend-maintenance-force-clear"
|
||||
:disabled="isBusy"
|
||||
@click="forceUpdateAndClearLocal"
|
||||
@click="openClearConfirmation"
|
||||
>
|
||||
<i class="fas fa-sync-alt" aria-hidden="true"></i>
|
||||
<span>
|
||||
@@ -93,6 +106,12 @@ onBeforeUnmount(() => {
|
||||
</span>
|
||||
</button>
|
||||
</section>
|
||||
<LocalDataResetDialog
|
||||
v-model="isClearConfirmationOpen"
|
||||
:busy="isBusy"
|
||||
@confirm="forceUpdateAndClearLocal"
|
||||
@dismiss="closeClearConfirmation"
|
||||
/>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
busy: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue", "confirm", "dismiss"]);
|
||||
|
||||
const dismissDialog = () => {
|
||||
emit("dismiss");
|
||||
emit("update:modelValue", false);
|
||||
};
|
||||
|
||||
const confirmDialog = () => {
|
||||
emit("confirm");
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="modelValue" class="local-data-reset-dialog" data-testid="local-data-reset-dialog">
|
||||
<button
|
||||
type="button"
|
||||
class="local-data-reset-dialog__backdrop"
|
||||
aria-label="Luk"
|
||||
:disabled="busy"
|
||||
@click="dismissDialog"
|
||||
></button>
|
||||
<section
|
||||
class="local-data-reset-dialog__panel"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="local-data-reset-dialog-title"
|
||||
>
|
||||
<h2 id="local-data-reset-dialog-title">Ryd lokale data?</h2>
|
||||
<p>
|
||||
Dette sletter login, localStorage, sessionStorage, browsercache og lokale appdata på denne enhed. Du bliver
|
||||
logget ud.
|
||||
</p>
|
||||
<footer class="local-data-reset-dialog__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="local-data-reset-dialog__button local-data-reset-dialog__button--secondary"
|
||||
data-testid="local-data-reset-cancel"
|
||||
:disabled="busy"
|
||||
@click="dismissDialog"
|
||||
>
|
||||
Nej
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="local-data-reset-dialog__button local-data-reset-dialog__button--danger"
|
||||
data-testid="local-data-reset-confirm"
|
||||
:disabled="busy"
|
||||
@click="confirmDialog"
|
||||
>
|
||||
Ja, ryd alt
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.local-data-reset-dialog {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 11000;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 18px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.local-data-reset-dialog__backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
border: 0;
|
||||
background: rgba(9, 20, 33, 0.48);
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.local-data-reset-dialog__backdrop:disabled {
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.local-data-reset-dialog__panel {
|
||||
position: relative;
|
||||
width: min(430px, calc(100vw - 36px));
|
||||
border: 1px solid #d5dde8;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.28);
|
||||
color: #172033;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.local-data-reset-dialog__panel h2 {
|
||||
margin: 0;
|
||||
padding: 18px 18px 8px;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.local-data-reset-dialog__panel p {
|
||||
margin: 0;
|
||||
padding: 0 18px 16px;
|
||||
color: #475467;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 550;
|
||||
line-height: 1.42;
|
||||
}
|
||||
|
||||
.local-data-reset-dialog__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding: 14px 18px 18px;
|
||||
border-top: 1px solid #edf1f5;
|
||||
}
|
||||
|
||||
.local-data-reset-dialog__button {
|
||||
min-height: 38px;
|
||||
padding: 0 14px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.86rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.local-data-reset-dialog__button:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.68;
|
||||
}
|
||||
|
||||
.local-data-reset-dialog__button--secondary {
|
||||
border: 1px solid #cfd8e3;
|
||||
background: #ffffff;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.local-data-reset-dialog__button--danger {
|
||||
border: 1px solid #b42318;
|
||||
background: #b42318;
|
||||
color: #ffffff;
|
||||
}
|
||||
</style>
|
||||
@@ -15,6 +15,29 @@ const getSelectedCustomerNumber = () => {
|
||||
|
||||
const MY_ACTIVE_WASH_ENDPOINT = '/modules/self-serve/lane/wash/my-active-wash';
|
||||
const ACTIVE_WASH_STARTED_STATUSES = new Set(['MACHINE_RELAY_ENABLED', 'MACHINE_STARTED']);
|
||||
const SELF_SERVE_HARDWARE_QUEUE_GROUP = 'SELF_SERVE_HARDWARE';
|
||||
const POS_SCANNER_QUEUE_GROUP = 'POS_SCANNER';
|
||||
const POS_STRIPE_QUEUE_GROUP = 'POS_STRIPE';
|
||||
const SELF_SERVE_HARDWARE_ENDPOINTS = [
|
||||
'/modules/self-serve/lane/command',
|
||||
'/modules/self-serve/lane/relay/',
|
||||
'/modules/self-serve/lane/gate/open',
|
||||
'/modules/self-serve/lane/force/machine',
|
||||
];
|
||||
const POS_LATENCY_QUEUE_RULES = [
|
||||
{
|
||||
endpoints: ['/modules/scanner/lpr'],
|
||||
queueGroup: POS_SCANNER_QUEUE_GROUP,
|
||||
concurrencyLimit: 2,
|
||||
retryByStatusCode: {},
|
||||
},
|
||||
{
|
||||
endpoints: ['/modules/stripe/invoice'],
|
||||
queueGroup: POS_STRIPE_QUEUE_GROUP,
|
||||
concurrencyLimit: 2,
|
||||
retryByStatusCode: {},
|
||||
},
|
||||
];
|
||||
|
||||
const normalizeStatus = (status) => String(status || '').trim().toUpperCase();
|
||||
|
||||
@@ -65,6 +88,52 @@ const normalizeActiveWashResponse = (url, method, response) => {
|
||||
};
|
||||
};
|
||||
|
||||
const isSelfServeHardwareMutation = (url, method) => {
|
||||
const normalizedMethod = String(method || '').trim().toUpperCase();
|
||||
if (normalizedMethod === 'GET') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedUrl = String(url || '');
|
||||
return SELF_SERVE_HARDWARE_ENDPOINTS.some((endpoint) => normalizedUrl.includes(endpoint));
|
||||
};
|
||||
|
||||
const findPosLatencyQueueRule = (url, method) => {
|
||||
const normalizedMethod = String(method || '').trim().toUpperCase();
|
||||
if (normalizedMethod === 'GET') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedUrl = String(url || '');
|
||||
return POS_LATENCY_QUEUE_RULES.find((rule) =>
|
||||
rule.endpoints.some((endpoint) => normalizedUrl.includes(endpoint))
|
||||
) || null;
|
||||
};
|
||||
|
||||
const buildRequestQueueOptions = (url, method, options = {}) => {
|
||||
const queueOptions = {
|
||||
retryByStatusCode: options?.retryByStatusCode,
|
||||
shouldRetry: options?.shouldRetry,
|
||||
queueGroup: options?.queueGroup,
|
||||
concurrencyLimit: options?.concurrencyLimit,
|
||||
};
|
||||
|
||||
if (isSelfServeHardwareMutation(url, method)) {
|
||||
queueOptions.retryByStatusCode ??= {};
|
||||
queueOptions.queueGroup ??= SELF_SERVE_HARDWARE_QUEUE_GROUP;
|
||||
queueOptions.concurrencyLimit ??= 1;
|
||||
}
|
||||
|
||||
const posQueueRule = findPosLatencyQueueRule(url, method);
|
||||
if (posQueueRule) {
|
||||
queueOptions.retryByStatusCode ??= posQueueRule.retryByStatusCode;
|
||||
queueOptions.queueGroup ??= posQueueRule.queueGroup;
|
||||
queueOptions.concurrencyLimit ??= posQueueRule.concurrencyLimit;
|
||||
}
|
||||
|
||||
return queueOptions;
|
||||
};
|
||||
|
||||
export const authenticatedRequest = (url, method, data, catchCallable = null, thenCallable = null, options = {}) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
@@ -105,7 +174,8 @@ export const authenticatedRequest = (url, method, data, catchCallable = null, th
|
||||
params: method === 'GET' ? data : null,
|
||||
data: method === 'GET' ? null : data,
|
||||
headers,
|
||||
}
|
||||
},
|
||||
...buildRequestQueueOptions(requestUrl, method, options),
|
||||
}
|
||||
)
|
||||
.catch((error) => {
|
||||
|
||||
@@ -11,6 +11,8 @@ import {useRouter} from "vue-router";
|
||||
import { showFooterInContent } from "@/components/viewport/conditions/ViewPortFooterOptions.vue";
|
||||
import { isHidden } from "@/components/viewport/page/headers/ViewportHeaderSettings.vue";
|
||||
import ConnectivityIssue from "@/views/errors/ConnectivityIssue.vue";
|
||||
import { inject } from "vue";
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const headerHeight = ref(60); // Default header height in pixels
|
||||
@@ -87,6 +89,7 @@ const isFooterInContent = computed(() => {
|
||||
style="max-height: 28px;"
|
||||
>
|
||||
<p class="is-size-7 has-text-grey-light mb-2 mt-0">© Truckwash ApS. All rights reserved.</p>
|
||||
<p class="is-size-7 has-text-grey-light mb-2 mt-0" v-show="SessionUser.functions.device.isMobile()">{{inject("VERSION")}}</p>
|
||||
</a>
|
||||
</div>
|
||||
</ViewportContent>
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import {BButton, BField, BIcon} from "buefy";
|
||||
import {computed, ref} from "vue";
|
||||
import {computed, onBeforeUnmount, ref, watch} from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { IS_DEV } from '@/config.js';
|
||||
import LocalDataResetDialog from "@/components/global/LocalDataResetDialog.vue";
|
||||
import { forceFrontendUpdateAndClearLocal } from "@/services/frontendMaintenance.js";
|
||||
|
||||
const router = useRouter();
|
||||
const HOME_LONG_PRESS_MS = 5000;
|
||||
const HOME_CLICK_SUPPRESS_MS = 1200;
|
||||
|
||||
type FooterPage = {
|
||||
to: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
const show = computed(() => {
|
||||
const path = router.currentRoute.value.path;
|
||||
@@ -12,10 +22,16 @@ const show = computed(() => {
|
||||
return path.startsWith("/user");
|
||||
});
|
||||
|
||||
const currentPath = computed(() => router.currentRoute.value.path);
|
||||
const isLocalDataResetOpen = ref(false);
|
||||
const isClearingLocalData = ref(false);
|
||||
const suppressHomeClickUntil = ref(0);
|
||||
let homeLongPressTimer: ReturnType<typeof window.setTimeout> | null = null;
|
||||
|
||||
/**
|
||||
* Page navigation footer for mobile devices
|
||||
*/
|
||||
const pages = {
|
||||
const pages: Record<string, FooterPage> = {
|
||||
home: {
|
||||
to: "/user",
|
||||
label: "Hjem",
|
||||
@@ -35,6 +51,78 @@ const pages = {
|
||||
disabled: false
|
||||
},
|
||||
}
|
||||
|
||||
const cancelHomeLongPress = () => {
|
||||
if (homeLongPressTimer !== null) {
|
||||
window.clearTimeout(homeLongPressTimer);
|
||||
homeLongPressTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const startHomeLongPress = (event: PointerEvent) => {
|
||||
if (event.pointerType === "mouse" && event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLocalDataResetOpen.value || isClearingLocalData.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
cancelHomeLongPress();
|
||||
homeLongPressTimer = window.setTimeout(() => {
|
||||
homeLongPressTimer = null;
|
||||
suppressHomeClickUntil.value = Date.now() + HOME_CLICK_SUPPRESS_MS;
|
||||
isLocalDataResetOpen.value = true;
|
||||
}, HOME_LONG_PRESS_MS);
|
||||
};
|
||||
|
||||
const footerButtonListeners = (key: string) =>
|
||||
key === "home"
|
||||
? {
|
||||
pointerdown: startHomeLongPress,
|
||||
pointerup: cancelHomeLongPress,
|
||||
pointercancel: cancelHomeLongPress,
|
||||
pointerleave: cancelHomeLongPress,
|
||||
}
|
||||
: {};
|
||||
|
||||
const isActiveFooterRoute = (path: string) => currentPath.value.endsWith(path);
|
||||
|
||||
const handlePageClick = (event: MouseEvent, key: string, page: FooterPage) => {
|
||||
if (page.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "home" && Date.now() <= suppressHomeClickUntil.value) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
suppressHomeClickUntil.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
router.push(page.to);
|
||||
};
|
||||
|
||||
const closeLocalDataReset = () => {
|
||||
isLocalDataResetOpen.value = false;
|
||||
};
|
||||
|
||||
const confirmLocalDataReset = async () => {
|
||||
if (isClearingLocalData.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isClearingLocalData.value = true;
|
||||
try {
|
||||
await forceFrontendUpdateAndClearLocal();
|
||||
} catch (error) {
|
||||
isClearingLocalData.value = false;
|
||||
console.error("Failed to clear local app data:", error);
|
||||
}
|
||||
};
|
||||
|
||||
watch(() => router.currentRoute.value.fullPath || router.currentRoute.value.path, cancelHomeLongPress);
|
||||
onBeforeUnmount(cancelHomeLongPress);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -45,13 +133,15 @@ const pages = {
|
||||
<template v-for="(page, key) in pages" :key="key">
|
||||
<div class="column has-text-centered">
|
||||
<b-button
|
||||
class="no-border-radius"
|
||||
:type="$route.path.endsWith(page.to) ? 'is-info is-outlined is-light p-1' : 'p-1 is-light is-outlined'"
|
||||
class="no-border-radius"
|
||||
:data-testid="`mobile-footer-${key}`"
|
||||
:type="isActiveFooterRoute(page.to) ? 'is-info is-outlined is-light p-1' : 'p-1 is-light is-outlined'"
|
||||
size="is-normal"
|
||||
@click="$router.push(page.to)"
|
||||
v-on="footerButtonListeners(key)"
|
||||
@click="handlePageClick($event, key, page)"
|
||||
iconPack="fas"
|
||||
expanded
|
||||
:disabled="page.disabled"
|
||||
:disabled="page.disabled"
|
||||
>
|
||||
<span>
|
||||
<span><b-icon pack="fas" :icon="page.icon"></b-icon></span>
|
||||
@@ -64,6 +154,12 @@ const pages = {
|
||||
</div>
|
||||
</b-field>
|
||||
</section>
|
||||
<LocalDataResetDialog
|
||||
v-model="isLocalDataResetOpen"
|
||||
:busy="isClearingLocalData"
|
||||
@confirm="confirmLocalDataReset"
|
||||
@dismiss="closeLocalDataReset"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -84,4 +180,4 @@ const pages = {
|
||||
border-top-right-radius: 4px;
|
||||
background-color: #2c3e50;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -9,6 +9,7 @@ const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||
const cameraStream = ref<MediaStream | null>(null);
|
||||
const isCameraActive = ref(false);
|
||||
const cameraErrorKey = ref('pos.camera_permission_denied');
|
||||
let captureIntervalId: ReturnType<typeof window.setInterval> | null = null;
|
||||
import { isCameraMounted, camera } from '@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue';
|
||||
|
||||
const getCameraErrorKey = (err: unknown) => {
|
||||
@@ -22,6 +23,10 @@ const getCameraErrorKey = (err: unknown) => {
|
||||
};
|
||||
|
||||
function startCamera() {
|
||||
if (cameraStream.value || isCameraActive.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const constraints = {
|
||||
video: {
|
||||
facingMode: 'environment',
|
||||
@@ -49,12 +54,14 @@ function startCamera() {
|
||||
navigator.mediaDevices.getUserMedia(constraints)
|
||||
.then((stream) => {
|
||||
isCameraActive.value = true;
|
||||
isCameraMounted.value = true;
|
||||
cameraStream.value = stream;
|
||||
if (videoRef.value) {
|
||||
videoRef.value.srcObject = stream;
|
||||
videoRef.value.setAttribute('playsinline', '');
|
||||
videoRef.value.play();
|
||||
}
|
||||
startCaptureInterval();
|
||||
})
|
||||
.catch((err) => {
|
||||
isCameraActive.value = false;
|
||||
@@ -63,7 +70,24 @@ function startCamera() {
|
||||
});
|
||||
}
|
||||
|
||||
function clearCaptureInterval() {
|
||||
if (captureIntervalId !== null) {
|
||||
window.clearInterval(captureIntervalId);
|
||||
captureIntervalId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startCaptureInterval() {
|
||||
clearCaptureInterval();
|
||||
captureIntervalId = window.setInterval(() => {
|
||||
if (isCameraActive.value) {
|
||||
getFrame();
|
||||
}
|
||||
}, camera.getImageCaptureDelay(false));
|
||||
}
|
||||
|
||||
function stopCamera() {
|
||||
clearCaptureInterval();
|
||||
if (cameraStream.value) {
|
||||
cameraStream.value.getTracks().forEach(track => track.stop());
|
||||
cameraStream.value = null;
|
||||
@@ -139,16 +163,8 @@ watch(() => camera.getZoom(), (newZoom) => {
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
// Emit a picture every 10 seconds
|
||||
if (!isCameraMounted.value) {
|
||||
isCameraMounted.value = true;
|
||||
setInterval(() => {
|
||||
if (isCameraActive.value) {
|
||||
getFrame();
|
||||
}
|
||||
}, camera.getImageCaptureDelay(false)); // Implement a method to get the delay based on camera settings
|
||||
startCamera();
|
||||
}
|
||||
isCameraMounted.value = true;
|
||||
startCamera();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
|
||||
+5
-5
@@ -82,11 +82,11 @@ export const REQUEST_QUEUE_CONFIG = Object.freeze({
|
||||
// Per-method concurrency limits
|
||||
concurrency: Object.freeze({
|
||||
GET: 10,
|
||||
POST: 1,
|
||||
PATCH: 1,
|
||||
PUT: 1,
|
||||
DELETE: 1,
|
||||
DEFAULT: 1,
|
||||
POST: 4,
|
||||
PATCH: 4,
|
||||
PUT: 4,
|
||||
DELETE: 4,
|
||||
DEFAULT: 4,
|
||||
}),
|
||||
// Delay between queue starts (0 = no pacing delay)
|
||||
spacingMs: 0,
|
||||
|
||||
@@ -1152,10 +1152,10 @@
|
||||
},
|
||||
"redirect": {
|
||||
"mobile_department_auto_select": {
|
||||
"manual_button": "Vælg afdeling manuelt",
|
||||
"manual_title": "Vælg din afdeling",
|
||||
"manual_loading": "Indlæser afdelinger...",
|
||||
"use_department": "Vælg {name}"
|
||||
"manual_button": "@.capitalize:{'words.generated.vælg'} @:{'words.generated.afdeling'} manuelt",
|
||||
"manual_title": "@.capitalize:{'words.generated.vælg'} din @:{'words.generated.afdeling'}",
|
||||
"manual_loading": "@.capitalize:{'words.generated.indlæser'} afdelinger...",
|
||||
"use_department": "@.capitalize:{'words.generated.vælg'} {name}"
|
||||
}
|
||||
},
|
||||
"replication": {
|
||||
|
||||
@@ -1262,10 +1262,10 @@
|
||||
},
|
||||
"redirect": {
|
||||
"mobile_department_auto_select": {
|
||||
"manual_button": "Abteilung manuell auswählen",
|
||||
"manual_title": "Wählen Sie Ihre Abteilung",
|
||||
"manual_loading": "Abteilungen werden geladen...",
|
||||
"use_department": "{name} auswählen"
|
||||
"manual_button": "@:{'words.generated.abteilung'} @:{'words.generated.manuell'} @:{'words.generated.auswahlen'}",
|
||||
"manual_title": "@.capitalize:{'words.generated.wahlen'} @.capitalize:{'words.generated.sie'} @.capitalize:{'words.generated.ihre'} @:{'words.generated.abteilung'}",
|
||||
"manual_loading": "@:{'words.generated.abteilungen'} @:{'words.generated.werden'} @:{'words.generated.geladen'}...",
|
||||
"use_department": "{name} @:{'words.generated.auswahlen'}"
|
||||
}
|
||||
},
|
||||
"replication": {
|
||||
|
||||
@@ -986,10 +986,10 @@
|
||||
},
|
||||
"redirect": {
|
||||
"mobile_department_auto_select": {
|
||||
"manual_button": "Select department manually",
|
||||
"manual_title": "Select your department",
|
||||
"manual_loading": "Loading departments...",
|
||||
"use_department": "Select {name}"
|
||||
"manual_button": "@.capitalize:{'words.generated.select'} @:{'words.generated.department'} @:{'words.generated.manually'}",
|
||||
"manual_title": "@.capitalize:{'words.generated.select'} @:{'words.generated.your'} @:{'words.generated.department'}",
|
||||
"manual_loading": "@.capitalize:{'words.generated.loading'} @:{'words.generated.departments'}...",
|
||||
"use_department": "@.capitalize:{'words.generated.select'} {name}"
|
||||
}
|
||||
},
|
||||
"replication": {
|
||||
|
||||
@@ -1367,7 +1367,12 @@
|
||||
"customer_registration_webhook_url_desc": "@:{'templates.generated.compat.configuration.slack.customer_registration_webhook_url_desc'}",
|
||||
"notification_settings": "@:{'templates.generated.compat.configuration.slack.notification_settings'}",
|
||||
"notification_settings_desc": "@:{'templates.generated.compat.configuration.slack.notification_settings_desc'}",
|
||||
"send_test_webhook": "@:{'templates.generated.compat.configuration.slack.send_test_webhook'}",
|
||||
"subtitle": "@:{'templates.generated.compat.configuration.slack.subtitle'}",
|
||||
"test_webhook_error": "@:{'templates.generated.compat.configuration.slack.test_webhook_error'}",
|
||||
"test_webhook_not_configured": "@:{'templates.generated.compat.configuration.slack.test_webhook_not_configured'}",
|
||||
"test_webhook_sent": "@:{'templates.generated.compat.configuration.slack.test_webhook_sent'}",
|
||||
"test_webhook_sent_success": "@:{'templates.generated.compat.configuration.slack.test_webhook_sent_success'}",
|
||||
"title": "@:{'templates.generated.compat.configuration.slack.title'}",
|
||||
"unavailable": "@:{'templates.generated.compat.configuration.slack.unavailable'}"
|
||||
},
|
||||
@@ -4059,6 +4064,14 @@
|
||||
},
|
||||
"title": "@:common.profile"
|
||||
},
|
||||
"redirect": {
|
||||
"mobile_department_auto_select": {
|
||||
"manual_button": "@:{'templates.redirect.mobile_department_auto_select.manual_button'}",
|
||||
"manual_title": "@:{'templates.redirect.mobile_department_auto_select.manual_title'}",
|
||||
"manual_loading": "@:{'templates.redirect.mobile_department_auto_select.manual_loading'}",
|
||||
"use_department": "@:{'templates.redirect.mobile_department_auto_select.use_department'}"
|
||||
}
|
||||
},
|
||||
"replication": {
|
||||
"actions": {
|
||||
"add": "@:{'templates.generated.compat.replication.actions.add'}",
|
||||
|
||||
@@ -1263,10 +1263,10 @@
|
||||
},
|
||||
"redirect": {
|
||||
"mobile_department_auto_select": {
|
||||
"manual_button": "Velg avdeling manuelt",
|
||||
"manual_title": "Velg din avdeling",
|
||||
"manual_loading": "Laster avdelinger...",
|
||||
"use_department": "Velg {name}"
|
||||
"manual_button": "@.capitalize:{'words.generated.velg'} @:{'words.generated.avdeling'} @:{'words.generated.manuelt'}",
|
||||
"manual_title": "@.capitalize:{'words.generated.velg'} @:{'words.generated.din'} @:{'words.generated.avdeling'}",
|
||||
"manual_loading": "@.capitalize:{'words.generated.laster'} @:{'words.generated.avdelinger'}...",
|
||||
"use_department": "@.capitalize:{'words.generated.velg'} {name}"
|
||||
}
|
||||
},
|
||||
"replication": {
|
||||
@@ -2454,7 +2454,12 @@
|
||||
"customer_registration_webhook_url_desc": "Slack-webhook som mottar en melding når en ny kunderegistrering lykkes. La feltet stå tomt for å deaktivere.",
|
||||
"notification_settings": "Varslingsinnstillinger",
|
||||
"notification_settings_desc": "Slack-webhooks for systemhendelser.",
|
||||
"send_test_webhook": "Send test-webhook",
|
||||
"subtitle": "Konfigurasjon av Slack-varsler",
|
||||
"test_webhook_error": "Kunne ikke sende Slack-testwebhooken.",
|
||||
"test_webhook_not_configured": "Lagre en webhook-URL for kunderegistreringer før du sender en test.",
|
||||
"test_webhook_sent": "Slack-test sendt",
|
||||
"test_webhook_sent_success": "Slack-testmeldingen for kunderegistrering ble sendt.",
|
||||
"title": "Slack-konfigurasjon",
|
||||
"unavailable": "Slack-konfigurasjonen er ikke tilgjengelig i denne API-utgivelsen."
|
||||
},
|
||||
|
||||
@@ -1313,10 +1313,10 @@
|
||||
},
|
||||
"redirect": {
|
||||
"mobile_department_auto_select": {
|
||||
"manual_button": "Välj avdelning manuellt",
|
||||
"manual_title": "Välj din avdelning",
|
||||
"manual_loading": "Laddar avdelningar...",
|
||||
"use_department": "Välj {name}"
|
||||
"manual_button": "@.capitalize:{'words.generated.valj'} @:{'words.generated.avdelning'} manuellt",
|
||||
"manual_title": "@.capitalize:{'words.generated.valj'} @:{'words.generated.din'} @:{'words.generated.avdelning'}",
|
||||
"manual_loading": "@:{'words.generated.laddar'} @:{'words.generated.avdelningar'}...",
|
||||
"use_department": "@.capitalize:{'words.generated.valj'} {name}"
|
||||
}
|
||||
},
|
||||
"replication": {
|
||||
|
||||
@@ -1376,7 +1376,12 @@
|
||||
"customer_registration_webhook_url_desc": "Slack-webhook der modtager en besked, når en ny kunderegistrering lykkes. Lad feltet være tomt for at deaktivere.",
|
||||
"notification_settings": "Notifikationer",
|
||||
"notification_settings_desc": "Slack-webhooks til systemhændelser.",
|
||||
"send_test_webhook": "Send test-webhook",
|
||||
"subtitle": "Konfiguration af Slack-notifikationer",
|
||||
"test_webhook_error": "Kunne ikke sende Slack-testwebhooken.",
|
||||
"test_webhook_not_configured": "Gem en webhook-URL til kunderegistreringer, før du sender en test.",
|
||||
"test_webhook_sent": "Slack-test sendt",
|
||||
"test_webhook_sent_success": "Slack-testbeskeden for kunderegistrering blev sendt.",
|
||||
"title": "Slack-konfiguration",
|
||||
"unavailable": "Slack-konfigurationen er ikke tilgængelig i denne API-udgivelse."
|
||||
},
|
||||
|
||||
@@ -1376,7 +1376,12 @@
|
||||
"customer_registration_webhook_url_desc": "Slack-Webhook, der eine Nachricht erhaelt, wenn eine neue Kundenregistrierung erfolgreich ist. Leer lassen, um dies zu deaktivieren.",
|
||||
"notification_settings": "Benachrichtigungseinstellungen",
|
||||
"notification_settings_desc": "Slack-Webhooks fuer Systemereignisse.",
|
||||
"send_test_webhook": "Test-Webhook senden",
|
||||
"subtitle": "Konfiguration von Slack-Benachrichtigungen",
|
||||
"test_webhook_error": "Der Slack-Test-Webhook konnte nicht gesendet werden.",
|
||||
"test_webhook_not_configured": "Speichern Sie zuerst eine Webhook-URL fuer Kundenregistrierungen.",
|
||||
"test_webhook_sent": "Slack-Test gesendet",
|
||||
"test_webhook_sent_success": "Die Slack-Testnachricht fuer Kundenregistrierungen wurde gesendet.",
|
||||
"title": "Slack-Konfiguration",
|
||||
"unavailable": "Die Slack-Konfiguration ist in dieser API-Version nicht verfuegbar."
|
||||
},
|
||||
|
||||
@@ -1376,7 +1376,12 @@
|
||||
"customer_registration_webhook_url_desc": "Slack webhook that receives a message when a new customer registration succeeds. Leave empty to disable.",
|
||||
"notification_settings": "Notification settings",
|
||||
"notification_settings_desc": "Slack webhooks for system events.",
|
||||
"send_test_webhook": "Send test webhook",
|
||||
"subtitle": "Configuration of Slack notifications",
|
||||
"test_webhook_error": "Could not send the Slack test webhook.",
|
||||
"test_webhook_not_configured": "Save a customer registration webhook URL before sending a test.",
|
||||
"test_webhook_sent": "Slack test sent",
|
||||
"test_webhook_sent_success": "The Slack customer registration test message was sent.",
|
||||
"title": "Slack configuration",
|
||||
"unavailable": "Slack configuration is not available on this API release."
|
||||
},
|
||||
|
||||
@@ -1376,7 +1376,12 @@
|
||||
"customer_registration_webhook_url_desc": "Slack-webhook som mottar en melding når en ny kunderegistrering lykkes. La feltet stå tomt for å deaktivere.",
|
||||
"notification_settings": "Varslingsinnstillinger",
|
||||
"notification_settings_desc": "Slack-webhooks for systemhendelser.",
|
||||
"send_test_webhook": "Send test-webhook",
|
||||
"subtitle": "Konfigurasjon av Slack-varsler",
|
||||
"test_webhook_error": "Kunne ikke sende Slack-testwebhooken.",
|
||||
"test_webhook_not_configured": "Lagre en webhook-URL for kunderegistreringer før du sender en test.",
|
||||
"test_webhook_sent": "Slack-test sendt",
|
||||
"test_webhook_sent_success": "Slack-testmeldingen for kunderegistrering ble sendt.",
|
||||
"title": "Slack-konfigurasjon",
|
||||
"unavailable": "Slack-konfigurasjonen er ikke tilgjengelig i denne API-utgivelsen."
|
||||
},
|
||||
|
||||
@@ -1376,7 +1376,12 @@
|
||||
"customer_registration_webhook_url_desc": "Slack-webhook som får ett meddelande när en ny kundregistrering lyckas. Lämna tomt för att inaktivera.",
|
||||
"notification_settings": "Aviseringsinställningar",
|
||||
"notification_settings_desc": "Slack-webhooks för systemhändelser.",
|
||||
"send_test_webhook": "Skicka test-webhook",
|
||||
"subtitle": "Konfiguration av Slack-aviseringar",
|
||||
"test_webhook_error": "Det gick inte att skicka Slack-testwebhooken.",
|
||||
"test_webhook_not_configured": "Spara en webhook-URL för kundregistreringar innan du skickar ett test.",
|
||||
"test_webhook_sent": "Slack-test skickat",
|
||||
"test_webhook_sent_success": "Slack-testmeddelandet för kundregistrering skickades.",
|
||||
"title": "Slack-konfiguration",
|
||||
"unavailable": "Slack-konfigurationen är inte tillgänglig i den här API-versionen."
|
||||
},
|
||||
|
||||
@@ -14,10 +14,10 @@
|
||||
},
|
||||
"redirect": {
|
||||
"mobile_department_auto_select": {
|
||||
"manual_button": "Vælg afdeling manuelt",
|
||||
"manual_title": "Vælg din afdeling",
|
||||
"manual_loading": "Indlæser afdelinger...",
|
||||
"use_department": "Vælg {name}"
|
||||
"manual_button": "@.capitalize:{'terms.glossary.vælg'} @:{'terms.glossary.afdeling'} manuelt",
|
||||
"manual_title": "@.capitalize:{'terms.glossary.vælg'} din @:{'terms.glossary.afdeling'}",
|
||||
"manual_loading": "@.capitalize:{'terms.glossary.indlæser'} afdelinger...",
|
||||
"use_department": "@.capitalize:{'terms.glossary.vælg'} {name}"
|
||||
}
|
||||
},
|
||||
"replication": {
|
||||
|
||||
@@ -14,10 +14,10 @@
|
||||
},
|
||||
"redirect": {
|
||||
"mobile_department_auto_select": {
|
||||
"manual_button": "Abteilung manuell auswählen",
|
||||
"manual_title": "Wählen Sie Ihre Abteilung",
|
||||
"manual_loading": "Abteilungen werden geladen...",
|
||||
"use_department": "{name} auswählen"
|
||||
"manual_button": "@:{'terms.glossary.abteilung'} @:{'terms.glossary.manuell'} @:{'terms.glossary.auswahlen'}",
|
||||
"manual_title": "@.capitalize:{'terms.glossary.wahlen'} @.capitalize:{'terms.glossary.sie'} @.capitalize:{'terms.glossary.ihre'} @:{'terms.glossary.abteilung'}",
|
||||
"manual_loading": "@:{'terms.glossary.abteilungen'} @:{'terms.glossary.werden'} @:{'terms.glossary.geladen'}...",
|
||||
"use_department": "{name} @:{'terms.glossary.auswahlen'}"
|
||||
}
|
||||
},
|
||||
"replication": {
|
||||
|
||||
@@ -14,10 +14,10 @@
|
||||
},
|
||||
"redirect": {
|
||||
"mobile_department_auto_select": {
|
||||
"manual_button": "Select department manually",
|
||||
"manual_title": "Select your department",
|
||||
"manual_loading": "Loading departments...",
|
||||
"use_department": "Select {name}"
|
||||
"manual_button": "@.capitalize:{'terms.glossary.select'} @:{'terms.glossary.department'} @:{'terms.glossary.manually'}",
|
||||
"manual_title": "@.capitalize:{'terms.glossary.select'} @:{'terms.glossary.your'} @:{'terms.glossary.department'}",
|
||||
"manual_loading": "@.capitalize:{'terms.glossary.loading'} @:{'terms.glossary.departments'}...",
|
||||
"use_department": "@.capitalize:{'terms.glossary.select'} {name}"
|
||||
}
|
||||
},
|
||||
"replication": {
|
||||
|
||||
@@ -189,7 +189,12 @@
|
||||
"customer_registration_webhook_url_desc": "@:{'phrases.compat.configuration.slack.customer_registration_webhook_url_desc'}",
|
||||
"notification_settings": "@:{'phrases.compat.configuration.slack.notification_settings'}",
|
||||
"notification_settings_desc": "@:{'phrases.compat.configuration.slack.notification_settings_desc'}",
|
||||
"send_test_webhook": "@:{'phrases.compat.configuration.slack.send_test_webhook'}",
|
||||
"subtitle": "@:{'phrases.compat.configuration.slack.subtitle'}",
|
||||
"test_webhook_error": "@:{'phrases.compat.configuration.slack.test_webhook_error'}",
|
||||
"test_webhook_not_configured": "@:{'phrases.compat.configuration.slack.test_webhook_not_configured'}",
|
||||
"test_webhook_sent": "@:{'phrases.compat.configuration.slack.test_webhook_sent'}",
|
||||
"test_webhook_sent_success": "@:{'phrases.compat.configuration.slack.test_webhook_sent_success'}",
|
||||
"title": "@:{'phrases.compat.configuration.slack.title'}",
|
||||
"unavailable": "@:{'phrases.compat.configuration.slack.unavailable'}"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"redirect": {
|
||||
"mobile_department_auto_select": {
|
||||
"manual_button": "@:{'phrases.redirect.mobile_department_auto_select.manual_button'}",
|
||||
"manual_title": "@:{'phrases.redirect.mobile_department_auto_select.manual_title'}",
|
||||
"manual_loading": "@:{'phrases.redirect.mobile_department_auto_select.manual_loading'}",
|
||||
"use_department": "@:{'phrases.redirect.mobile_department_auto_select.use_department'}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -163,7 +163,12 @@
|
||||
"customer_registration_webhook_url_desc": "Slack-webhook som mottar en melding når en ny kunderegistrering lykkes. La feltet stå tomt for å deaktivere.",
|
||||
"notification_settings": "Varslingsinnstillinger",
|
||||
"notification_settings_desc": "Slack-webhooks for systemhendelser.",
|
||||
"send_test_webhook": "Send test-webhook",
|
||||
"subtitle": "Konfigurasjon av Slack-varsler",
|
||||
"test_webhook_error": "Kunne ikke sende Slack-testwebhooken.",
|
||||
"test_webhook_not_configured": "Lagre en webhook-URL for kunderegistreringer før du sender en test.",
|
||||
"test_webhook_sent": "Slack-test sendt",
|
||||
"test_webhook_sent_success": "Slack-testmeldingen for kunderegistrering ble sendt.",
|
||||
"title": "Slack-konfigurasjon",
|
||||
"unavailable": "Slack-konfigurasjonen er ikke tilgjengelig i denne API-utgivelsen."
|
||||
},
|
||||
|
||||
@@ -14,10 +14,10 @@
|
||||
},
|
||||
"redirect": {
|
||||
"mobile_department_auto_select": {
|
||||
"manual_button": "Velg avdeling manuelt",
|
||||
"manual_title": "Velg din avdeling",
|
||||
"manual_loading": "Laster avdelinger...",
|
||||
"use_department": "Velg {name}"
|
||||
"manual_button": "@.capitalize:{'terms.glossary.velg'} @:{'terms.glossary.avdeling'} @:{'terms.glossary.manuelt'}",
|
||||
"manual_title": "@.capitalize:{'terms.glossary.velg'} @:{'terms.glossary.din'} @:{'terms.glossary.avdeling'}",
|
||||
"manual_loading": "@.capitalize:{'terms.glossary.laster'} @:{'terms.glossary.avdelinger'}...",
|
||||
"use_department": "@.capitalize:{'terms.glossary.velg'} {name}"
|
||||
}
|
||||
},
|
||||
"replication": {
|
||||
|
||||
@@ -14,10 +14,10 @@
|
||||
},
|
||||
"redirect": {
|
||||
"mobile_department_auto_select": {
|
||||
"manual_button": "Välj avdelning manuellt",
|
||||
"manual_title": "Välj din avdelning",
|
||||
"manual_loading": "Laddar avdelningar...",
|
||||
"use_department": "Välj {name}"
|
||||
"manual_button": "@.capitalize:{'terms.glossary.valj'} @:{'terms.glossary.avdelning'} manuellt",
|
||||
"manual_title": "@.capitalize:{'terms.glossary.valj'} @:{'terms.glossary.din'} @:{'terms.glossary.avdelning'}",
|
||||
"manual_loading": "@:{'terms.glossary.laddar'} @:{'terms.glossary.avdelningar'}...",
|
||||
"use_department": "@.capitalize:{'terms.glossary.valj'} {name}"
|
||||
}
|
||||
},
|
||||
"replication": {
|
||||
|
||||
@@ -116,6 +116,7 @@ const app = createApp(App)
|
||||
.provide('Colors', Colors)
|
||||
.provide('IS_DEV', IS_DEV)
|
||||
.provide('API_URL', getReleaseRuntimeApiBaseUrl())
|
||||
.provide('VERSION', `${formatCommit(VITE_COMMIT_HASH)} @ ${formatDateTime(VITE_BUILD_DATE)}`);
|
||||
|
||||
installReleaseErrorInstrumentation(app, Router);
|
||||
app.mount('#app');
|
||||
|
||||
@@ -1,24 +1,15 @@
|
||||
const CACHE_INVALIDATION_THROTTLE_MS = 30 * 1000;
|
||||
const CACHE_BUST_PARAM = "force_update";
|
||||
const PRESERVED_LOCAL_STORAGE_KEYS = ["token"];
|
||||
const KNOWN_INDEXED_DB_NAMES = ["workbox-expiration", "workbox-background-sync", "pleno", "truckwash"];
|
||||
|
||||
let lastErrorInvalidationAt = 0;
|
||||
|
||||
const browserWindow = () => (typeof window !== "undefined" ? window : null);
|
||||
const browserNavigator = () => (typeof navigator !== "undefined" ? navigator : null);
|
||||
|
||||
const clearStorage = (storage, preservedKeys = []) => {
|
||||
const clearStorage = (storage) => {
|
||||
try {
|
||||
const preservedEntries = preservedKeys
|
||||
.map((key) => [key, storage?.getItem(key)])
|
||||
.filter(([, value]) => value !== null && value !== undefined);
|
||||
|
||||
storage?.clear();
|
||||
|
||||
preservedEntries.forEach(([key, value]) => {
|
||||
storage?.setItem(key, value);
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -60,6 +51,62 @@ const unregisterServiceWorkers = async () => {
|
||||
return registrations.length;
|
||||
};
|
||||
|
||||
const browserIndexedDb = () => {
|
||||
const win = browserWindow();
|
||||
if (win?.indexedDB) {
|
||||
return win.indexedDB;
|
||||
}
|
||||
|
||||
return typeof indexedDB !== "undefined" ? indexedDB : null;
|
||||
};
|
||||
|
||||
const deleteIndexedDatabase = (indexedDb, databaseName) =>
|
||||
new Promise((resolve) => {
|
||||
if (!indexedDb || !databaseName) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const request = indexedDb.deleteDatabase(databaseName);
|
||||
request.onsuccess = () => resolve(true);
|
||||
request.onerror = () => resolve(false);
|
||||
request.onblocked = () => resolve(false);
|
||||
} catch {
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
|
||||
const getIndexedDatabaseNames = async (indexedDb) => {
|
||||
if (typeof indexedDb?.databases !== "function") {
|
||||
return KNOWN_INDEXED_DB_NAMES;
|
||||
}
|
||||
|
||||
try {
|
||||
const databases = await indexedDb.databases();
|
||||
return [
|
||||
...new Set(
|
||||
databases
|
||||
.map((database) => database?.name)
|
||||
.filter((databaseName) => typeof databaseName === "string" && databaseName.length > 0)
|
||||
),
|
||||
];
|
||||
} catch {
|
||||
return KNOWN_INDEXED_DB_NAMES;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteIndexedDatabases = async () => {
|
||||
const indexedDb = browserIndexedDb();
|
||||
if (!indexedDb) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const databaseNames = await getIndexedDatabaseNames(indexedDb);
|
||||
await Promise.all(databaseNames.map((databaseName) => deleteIndexedDatabase(indexedDb, databaseName)));
|
||||
return databaseNames;
|
||||
};
|
||||
|
||||
const reloadWithCacheBust = () => {
|
||||
const win = browserWindow();
|
||||
if (!win?.location) {
|
||||
@@ -83,13 +130,17 @@ export const invalidateFrontendCachesAfterError = async ({ now = Date.now() } =
|
||||
|
||||
export const forceFrontendUpdateAndClearLocal = async ({ reload = reloadWithCacheBust } = {}) => {
|
||||
const win = browserWindow();
|
||||
const [cacheNames, serviceWorkerRegistrations] = await Promise.all([clearCacheStorage(), unregisterServiceWorkers()]);
|
||||
|
||||
clearStorage(win?.localStorage, PRESERVED_LOCAL_STORAGE_KEYS);
|
||||
clearStorage(win?.localStorage);
|
||||
clearStorage(win?.sessionStorage);
|
||||
|
||||
const [cacheNames, serviceWorkerRegistrations, indexedDatabaseNames] = await Promise.all([
|
||||
clearCacheStorage(),
|
||||
unregisterServiceWorkers(),
|
||||
deleteIndexedDatabases(),
|
||||
]);
|
||||
|
||||
reload();
|
||||
return { cacheNames, serviceWorkerRegistrations };
|
||||
return { cacheNames, serviceWorkerRegistrations, indexedDatabaseNames };
|
||||
};
|
||||
|
||||
export const __resetFrontendMaintenanceForTests = () => {
|
||||
|
||||
@@ -38,7 +38,7 @@ const requestQueueStateMutable = reactive({
|
||||
});
|
||||
|
||||
const requestQueue = [];
|
||||
const activeWorkersByMethod = {};
|
||||
const activeWorkersByKey = {};
|
||||
let activeWorkers = 0;
|
||||
let requestIdCounter = 0;
|
||||
let drainTimer = null;
|
||||
@@ -71,6 +71,14 @@ const normalizeMethod = (value) => {
|
||||
return value.trim().toUpperCase();
|
||||
};
|
||||
|
||||
const normalizeQueueGroup = (value) => {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
|
||||
return value.trim().toUpperCase();
|
||||
};
|
||||
|
||||
const normalizeUrl = (value) => {
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
return "(unknown endpoint)";
|
||||
@@ -231,10 +239,28 @@ const getMethodConcurrencyLimit = (method) => {
|
||||
return Math.max(1, Number(configuredLimit) || 1);
|
||||
};
|
||||
|
||||
const getJobConcurrencyKey = (job) => {
|
||||
const queueGroup = normalizeQueueGroup(job.queueGroup);
|
||||
if (queueGroup) {
|
||||
return `GROUP:${queueGroup}`;
|
||||
}
|
||||
|
||||
return `METHOD:${normalizeMethod(job.method)}`;
|
||||
};
|
||||
|
||||
const getJobConcurrencyLimit = (job) => {
|
||||
const configuredLimit = Number.parseInt(String(job.concurrencyLimit ?? ""), 10);
|
||||
if (Number.isInteger(configuredLimit) && configuredLimit > 0) {
|
||||
return configuredLimit;
|
||||
}
|
||||
|
||||
return getMethodConcurrencyLimit(job.method);
|
||||
};
|
||||
|
||||
const canRunJob = (job) => {
|
||||
const method = normalizeMethod(job.method);
|
||||
const activeForMethod = Number(activeWorkersByMethod[method] || 0);
|
||||
return activeForMethod < getMethodConcurrencyLimit(method);
|
||||
const concurrencyKey = getJobConcurrencyKey(job);
|
||||
const activeForKey = Number(activeWorkersByKey[concurrencyKey] || 0);
|
||||
return activeForKey < getJobConcurrencyLimit(job);
|
||||
};
|
||||
|
||||
const getNextRunnableJobIndex = () => {
|
||||
@@ -361,6 +387,7 @@ const upsertActiveRequest = (job, startedAt) => {
|
||||
const nextActive = [...requestQueueStateMutable.activeRequests, {
|
||||
id: job.id,
|
||||
method: normalizeMethod(job.method),
|
||||
queueGroup: normalizeQueueGroup(job.queueGroup) || null,
|
||||
url: job.url,
|
||||
queuedAt: job.enqueuedAt,
|
||||
startedAt,
|
||||
@@ -474,10 +501,12 @@ export const reportComponentMissingPermission = (permission, options = {}) => {
|
||||
|
||||
const runJob = (job) => {
|
||||
const method = normalizeMethod(job.method);
|
||||
const concurrencyKey = getJobConcurrencyKey(job);
|
||||
const queueGroup = normalizeQueueGroup(job.queueGroup) || null;
|
||||
const startedAt = Date.now();
|
||||
|
||||
activeWorkers += 1;
|
||||
activeWorkersByMethod[method] = Number(activeWorkersByMethod[method] || 0) + 1;
|
||||
activeWorkersByKey[concurrencyKey] = Number(activeWorkersByKey[concurrencyKey] || 0) + 1;
|
||||
lastRequestStartedAt = startedAt;
|
||||
upsertActiveRequest(job, startedAt);
|
||||
syncQueueCounters();
|
||||
@@ -490,6 +519,7 @@ const runJob = (job) => {
|
||||
pushRecentRequest({
|
||||
id: job.id,
|
||||
method,
|
||||
queueGroup,
|
||||
url: job.url,
|
||||
success: true,
|
||||
statusCode: getResponseStatusCode(response),
|
||||
@@ -524,6 +554,7 @@ const runJob = (job) => {
|
||||
pushRecentRequest({
|
||||
id: job.id,
|
||||
method,
|
||||
queueGroup,
|
||||
url: job.url,
|
||||
success: false,
|
||||
statusCode,
|
||||
@@ -537,6 +568,7 @@ const runJob = (job) => {
|
||||
pushErrorRequest({
|
||||
id: job.id,
|
||||
method,
|
||||
queueGroup,
|
||||
url: job.url,
|
||||
statusCode,
|
||||
attemptCount: Math.max(1, Number(error?.__queueAttemptCount) || 1),
|
||||
@@ -573,9 +605,9 @@ const runJob = (job) => {
|
||||
})
|
||||
.finally(() => {
|
||||
activeWorkers -= 1;
|
||||
activeWorkersByMethod[method] = Math.max(0, Number(activeWorkersByMethod[method] || 1) - 1);
|
||||
if (activeWorkersByMethod[method] === 0) {
|
||||
delete activeWorkersByMethod[method];
|
||||
activeWorkersByKey[concurrencyKey] = Math.max(0, Number(activeWorkersByKey[concurrencyKey] || 1) - 1);
|
||||
if (activeWorkersByKey[concurrencyKey] === 0) {
|
||||
delete activeWorkersByKey[concurrencyKey];
|
||||
}
|
||||
removeActiveRequest(job.id);
|
||||
syncQueueCounters();
|
||||
@@ -625,6 +657,8 @@ export const enqueueRequest = (requestFactory, options = {}) => {
|
||||
requestData: options.requestData || null,
|
||||
retryByStatusCode: options.retryByStatusCode || null,
|
||||
shouldRetry: typeof options.shouldRetry === "function" ? options.shouldRetry : null,
|
||||
queueGroup: normalizeQueueGroup(options.queueGroup),
|
||||
concurrencyLimit: options.concurrencyLimit || null,
|
||||
});
|
||||
requestQueueStateMutable.batchTotal += 1;
|
||||
syncQueueCounters();
|
||||
@@ -649,7 +683,7 @@ export const __resetRequestQueueForTests = () => {
|
||||
requestQueue.length = 0;
|
||||
activeWorkers = 0;
|
||||
requestIdCounter = 0;
|
||||
Object.keys(activeWorkersByMethod).forEach((method) => delete activeWorkersByMethod[method]);
|
||||
Object.keys(activeWorkersByKey).forEach((key) => delete activeWorkersByKey[key]);
|
||||
lastRequestStartedAt = 0;
|
||||
clearDrainTimer();
|
||||
|
||||
|
||||
@@ -1369,7 +1369,7 @@ const applyServerActiveWash = async (activeWash: any) => {
|
||||
};
|
||||
|
||||
const restoreServerActiveWash = async () => {
|
||||
if (isRestoringServerActiveWash.value) {
|
||||
if (isMyWashStartUnmounted.value || isRestoringServerActiveWash.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1385,6 +1385,10 @@ const restoreServerActiveWash = async () => {
|
||||
isRestoringServerActiveWash.value = true;
|
||||
try {
|
||||
const activeWash = await fetchServerActiveWash();
|
||||
if (isMyWashStartUnmounted.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeWash) {
|
||||
shouldRestoreServerActiveWash.value = false;
|
||||
await applyServerActiveWash(activeWash);
|
||||
|
||||
@@ -2610,8 +2610,9 @@ test.describe("All-in-one self-serve studio", () => {
|
||||
});
|
||||
expect(focusedNodeMetrics.count).toBeGreaterThanOrEqual(4);
|
||||
const isWebKitProject = testInfo.project.name.startsWith("webkit-");
|
||||
const isMobileProject = testInfo.project.name.includes("mobile");
|
||||
const maxFocusedSpreadX = isWebKitProject ? 1120 : 1120;
|
||||
const maxFocusedSpreadY = isWebKitProject ? 560 : 460;
|
||||
const maxFocusedSpreadY = isWebKitProject || isMobileProject ? 560 : 460;
|
||||
expect(focusedNodeMetrics.spreadX).toBeLessThan(maxFocusedSpreadX);
|
||||
expect(focusedNodeMetrics.spreadY).toBeLessThan(maxFocusedSpreadY);
|
||||
await page.getByTestId("studio-filter-lane").selectOption({ label: "Lane 7" });
|
||||
|
||||
@@ -562,7 +562,7 @@ test.describe("Self-serve wash", () => {
|
||||
await expect(page.getByTestId("self-serve-lane-step")).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("vehicle next button shows a loading indicator while self-serve data loads", async ({ page }) => {
|
||||
test("vehicle next button is disabled while self-serve data loads", async ({ page }) => {
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions: ["user"],
|
||||
@@ -587,7 +587,6 @@ test.describe("Self-serve wash", () => {
|
||||
|
||||
await nextButton.click();
|
||||
|
||||
await expect(nextButton).toHaveClass(/is-loading/);
|
||||
await expect(nextButton).toBeDisabled();
|
||||
await expect(page.getByTestId("self-serve-questions-step")).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
@@ -238,4 +238,158 @@ describe("authenticatedRequest", () => {
|
||||
expect(requestQueueState.batchCompleted).toBe(3);
|
||||
expect(requestQueueState.batchFailed).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps self-serve hardware commands serial without blocking ordinary POST requests", async () => {
|
||||
const hardwareOne = createDeferred();
|
||||
const hardwareTwo = createDeferred();
|
||||
const ordinaryPost = createDeferred();
|
||||
axiosMock.mockImplementation(({ url }) => {
|
||||
if (url.includes("/modules/self-serve/lane/command")) {
|
||||
return hardwareOne.promise;
|
||||
}
|
||||
if (url.includes("/modules/self-serve/lane/relay/machine/enable")) {
|
||||
return hardwareTwo.promise;
|
||||
}
|
||||
if (url.endsWith("/orders")) {
|
||||
return ordinaryPost.promise;
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected request URL: ${url}`));
|
||||
});
|
||||
|
||||
const request1 = authenticatedRequest("/modules/self-serve/lane/command", "post", {
|
||||
lane_id: 7,
|
||||
command: "STOP",
|
||||
});
|
||||
const request2 = authenticatedRequest("/modules/self-serve/lane/relay/machine/enable", "post", {
|
||||
lane_id: 7,
|
||||
});
|
||||
const request3 = authenticatedRequest("/orders", "post", {
|
||||
reference: "ordinary-post",
|
||||
});
|
||||
|
||||
await flushManyMicrotasks();
|
||||
|
||||
expect(axiosMock).toHaveBeenCalledTimes(2);
|
||||
expect(axiosMock.mock.calls.map(([config]) => config.url)).toEqual([
|
||||
expect.stringContaining("/modules/self-serve/lane/command"),
|
||||
expect.stringMatching(/\/orders$/),
|
||||
]);
|
||||
expect(requestQueueState.active).toBe(2);
|
||||
expect(requestQueueState.pending).toBe(1);
|
||||
|
||||
ordinaryPost.resolve({ status: 200, data: { id: 1 } });
|
||||
await flushManyMicrotasks();
|
||||
expect(axiosMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
hardwareOne.resolve({ status: 200, data: { ok: true } });
|
||||
await flushManyMicrotasks();
|
||||
|
||||
expect(axiosMock).toHaveBeenCalledTimes(3);
|
||||
expect(axiosMock.mock.calls[2][0].url).toContain("/modules/self-serve/lane/relay/machine/enable");
|
||||
|
||||
hardwareTwo.resolve({ status: 200, data: { ok: true } });
|
||||
await expect(Promise.all([request1, request2, request3])).resolves.toHaveLength(3);
|
||||
});
|
||||
|
||||
it("keeps POS scanner and Stripe invoice requests from blocking ordinary POS order mutations", async () => {
|
||||
const scannerOne = createDeferred();
|
||||
const scannerTwo = createDeferred();
|
||||
const stripeInvoice = createDeferred();
|
||||
const orderCreate = createDeferred();
|
||||
const scannerResponses = [scannerOne, scannerTwo];
|
||||
axiosMock.mockImplementation(({ url }) => {
|
||||
if (url.includes("/modules/scanner/lpr")) {
|
||||
return scannerResponses.shift()?.promise;
|
||||
}
|
||||
if (url.includes("/modules/stripe/invoice")) {
|
||||
return stripeInvoice.promise;
|
||||
}
|
||||
if (url.endsWith("/orders")) {
|
||||
return orderCreate.promise;
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected request URL: ${url}`));
|
||||
});
|
||||
|
||||
const request1 = authenticatedRequest("/modules/scanner/lpr", "post", { base64_image: "image-one" });
|
||||
const request2 = authenticatedRequest("/modules/scanner/lpr", "post", { base64_image: "image-two" });
|
||||
const request3 = authenticatedRequest("/modules/stripe/invoice", "post", { order_id: 42, email: "a@example.test" });
|
||||
const request4 = authenticatedRequest("/orders", "post", { department_id: 3, reference: "pos-order" });
|
||||
|
||||
await flushManyMicrotasks();
|
||||
|
||||
expect(axiosMock).toHaveBeenCalledTimes(4);
|
||||
expect(axiosMock.mock.calls.map(([config]) => config.url)).toEqual([
|
||||
expect.stringContaining("/modules/scanner/lpr"),
|
||||
expect.stringContaining("/modules/scanner/lpr"),
|
||||
expect.stringContaining("/modules/stripe/invoice"),
|
||||
expect.stringMatching(/\/orders$/),
|
||||
]);
|
||||
expect(requestQueueState.active).toBe(4);
|
||||
expect(requestQueueState.pending).toBe(0);
|
||||
expect(requestQueueState.activeRequests.map((request) => request.queueGroup)).toEqual([
|
||||
"POS_SCANNER",
|
||||
"POS_SCANNER",
|
||||
"POS_STRIPE",
|
||||
null,
|
||||
]);
|
||||
|
||||
scannerOne.resolve({ status: 200, data: { success: true } });
|
||||
scannerTwo.resolve({ status: 200, data: { success: true } });
|
||||
stripeInvoice.resolve({ status: 200, data: { id: "in_1" } });
|
||||
orderCreate.resolve({ status: 200, data: { id: 42 } });
|
||||
|
||||
await expect(Promise.all([request1, request2, request3, request4])).resolves.toHaveLength(4);
|
||||
});
|
||||
|
||||
it("does not retry POS Stripe invoice mutations", async () => {
|
||||
__configureRequestQueueForTests({
|
||||
retryByStatusCode: { 500: 1 },
|
||||
retryDelayBaseMs: 0,
|
||||
retryDelayMaxMs: 0,
|
||||
retryDelayJitterMs: 0,
|
||||
});
|
||||
axiosMock.mockRejectedValue({
|
||||
response: {
|
||||
status: 500,
|
||||
data: { message: "temporary Stripe failure" },
|
||||
},
|
||||
message: "Request failed",
|
||||
});
|
||||
|
||||
await expect(
|
||||
authenticatedRequest("/modules/stripe/invoice", "post", {
|
||||
order_id: 42,
|
||||
email: "a@example.test",
|
||||
})
|
||||
).rejects.toMatchObject({ response: { status: 500 } });
|
||||
|
||||
expect(axiosMock).toHaveBeenCalledTimes(1);
|
||||
expect(requestQueueState.batchFailed).toBe(1);
|
||||
});
|
||||
|
||||
it("does not retry non-idempotent self-serve hardware mutations", async () => {
|
||||
__configureRequestQueueForTests({
|
||||
retryByStatusCode: { 500: 1 },
|
||||
retryDelayBaseMs: 0,
|
||||
retryDelayMaxMs: 0,
|
||||
retryDelayJitterMs: 0,
|
||||
});
|
||||
axiosMock.mockRejectedValue({
|
||||
response: {
|
||||
status: 500,
|
||||
data: { message: "temporary backend failure" },
|
||||
},
|
||||
message: "Request failed",
|
||||
});
|
||||
|
||||
await expect(
|
||||
authenticatedRequest("/modules/self-serve/lane/command", "post", {
|
||||
lane_id: 7,
|
||||
command: "STOP",
|
||||
})
|
||||
).rejects.toMatchObject({ response: { status: 500 } });
|
||||
|
||||
expect(axiosMock).toHaveBeenCalledTimes(1);
|
||||
expect(requestQueueState.batchFailed).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -228,6 +228,44 @@ describe("axios request queue interceptor", () => {
|
||||
expect(responses.map((item) => item.data.id)).toEqual([1, 2, 3, 4, 5, 6]);
|
||||
});
|
||||
|
||||
it("allows multiple ordinary POST requests by default to use available PHP workers", async () => {
|
||||
__resetAxiosRequestQueueInstallerForTests();
|
||||
__resetRequestQueueForTests();
|
||||
__resetReleaseTimelineForTests();
|
||||
installAxiosRequestQueue();
|
||||
|
||||
const allDeferred = Array.from({ length: 5 }, () => createDeferred());
|
||||
const pendingAdapters = [...allDeferred];
|
||||
const adapter = () => pendingAdapters.shift()?.promise;
|
||||
|
||||
const requests = Array.from({ length: 5 }, (_, index) =>
|
||||
axios({
|
||||
url: `/queue-post-${index + 1}`,
|
||||
method: "POST",
|
||||
adapter,
|
||||
})
|
||||
);
|
||||
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(requestQueueState.active).toBe(4);
|
||||
expect(requestQueueState.pending).toBe(1);
|
||||
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
allDeferred[index].resolve(createResponse({ id: index + 1 }));
|
||||
}
|
||||
|
||||
await flushManyMicrotasks();
|
||||
|
||||
expect(requestQueueState.active).toBe(1);
|
||||
expect(requestQueueState.pending).toBe(0);
|
||||
|
||||
allDeferred[4].resolve(createResponse({ id: 5 }));
|
||||
const responses = await Promise.all(requests);
|
||||
|
||||
expect(responses.map((item) => item.data.id)).toEqual([1, 2, 3, 4, 5]);
|
||||
});
|
||||
|
||||
it("retries configured response status codes", async () => {
|
||||
__configureRequestQueueForTests({
|
||||
maxConcurrentGet: 1,
|
||||
|
||||
@@ -19,8 +19,8 @@ const pressShift = () => {
|
||||
};
|
||||
|
||||
describe("FrontendMaintenanceMenu", () => {
|
||||
it("opens after three Shift presses and runs the force update cleanup after confirmation", async () => {
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
it("opens after three Shift presses and runs the force update cleanup after in-app confirmation", async () => {
|
||||
const confirmSpy = vi.spyOn(window, "confirm");
|
||||
mount(FrontendMaintenanceMenu, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
@@ -39,7 +39,12 @@ describe("FrontendMaintenanceMenu", () => {
|
||||
document.body.querySelector("[data-testid='frontend-maintenance-force-clear']").click();
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(window.confirm).toHaveBeenCalledTimes(1);
|
||||
expect(document.body.querySelector("[data-testid='local-data-reset-dialog']")).not.toBeNull();
|
||||
|
||||
document.body.querySelector("[data-testid='local-data-reset-confirm']").click();
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(confirmSpy).not.toHaveBeenCalled();
|
||||
expect(forceFrontendUpdateAndClearLocal).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,8 +6,9 @@ import {
|
||||
invalidateFrontendCachesAfterError,
|
||||
} from "@/services/frontendMaintenance.js";
|
||||
|
||||
const installBrowserMaintenanceMocks = () => {
|
||||
const installBrowserMaintenanceMocks = ({ indexedDatabaseNames = [] } = {}) => {
|
||||
const deletedCaches = [];
|
||||
const deletedIndexedDatabases = [];
|
||||
const update = vi.fn(async () => true);
|
||||
const unregister = vi.fn(async () => true);
|
||||
const getRegistrations = vi.fn(async () => [{ update, unregister }]);
|
||||
@@ -18,6 +19,15 @@ const installBrowserMaintenanceMocks = () => {
|
||||
return true;
|
||||
}),
|
||||
};
|
||||
const indexedDB = {
|
||||
databases: vi.fn(async () => indexedDatabaseNames.map((name) => ({ name }))),
|
||||
deleteDatabase: vi.fn((databaseName) => {
|
||||
deletedIndexedDatabases.push(databaseName);
|
||||
const request = {};
|
||||
queueMicrotask(() => request.onsuccess?.());
|
||||
return request;
|
||||
}),
|
||||
};
|
||||
|
||||
Object.defineProperty(window, "caches", {
|
||||
configurable: true,
|
||||
@@ -27,8 +37,12 @@ const installBrowserMaintenanceMocks = () => {
|
||||
configurable: true,
|
||||
value: { getRegistrations },
|
||||
});
|
||||
Object.defineProperty(window, "indexedDB", {
|
||||
configurable: true,
|
||||
value: indexedDB,
|
||||
});
|
||||
|
||||
return { caches, deletedCaches, getRegistrations, unregister, update };
|
||||
return { caches, deletedCaches, deletedIndexedDatabases, getRegistrations, indexedDB, unregister, update };
|
||||
};
|
||||
|
||||
describe("frontend maintenance", () => {
|
||||
@@ -39,6 +53,9 @@ describe("frontend maintenance", () => {
|
||||
afterEach(() => {
|
||||
__resetFrontendMaintenanceForTests();
|
||||
vi.restoreAllMocks();
|
||||
Reflect.deleteProperty(window, "caches");
|
||||
Reflect.deleteProperty(window, "indexedDB");
|
||||
Reflect.deleteProperty(navigator, "serviceWorker");
|
||||
});
|
||||
|
||||
it("invalidates browser caches and asks service workers to update after frontend errors", async () => {
|
||||
@@ -66,8 +83,10 @@ describe("frontend maintenance", () => {
|
||||
expect(mocks.update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("clears local state except the session token, unregisters service workers, clears caches, and reloads", async () => {
|
||||
const mocks = installBrowserMaintenanceMocks();
|
||||
it("clears all local state, unregisters service workers, clears caches, deletes IndexedDB, and reloads", async () => {
|
||||
const mocks = installBrowserMaintenanceMocks({
|
||||
indexedDatabaseNames: ["workbox-expiration", "truckwash-offline"],
|
||||
});
|
||||
const reload = vi.fn();
|
||||
localStorage.setItem("token", "secret-token");
|
||||
localStorage.setItem("draft", "local-value");
|
||||
@@ -76,10 +95,33 @@ describe("frontend maintenance", () => {
|
||||
const result = await forceFrontendUpdateAndClearLocal({ reload });
|
||||
|
||||
expect(result.cacheNames).toEqual(["pleno-api-cache", "pleno-website-cache"]);
|
||||
expect(result.indexedDatabaseNames).toEqual(["workbox-expiration", "truckwash-offline"]);
|
||||
expect(mocks.unregister).toHaveBeenCalledTimes(1);
|
||||
expect(localStorage.getItem("token")).toBe("secret-token");
|
||||
expect(mocks.deletedCaches).toEqual(["pleno-api-cache", "pleno-website-cache"]);
|
||||
expect(mocks.deletedIndexedDatabases).toEqual(["workbox-expiration", "truckwash-offline"]);
|
||||
expect(localStorage.getItem("token")).toBeNull();
|
||||
expect(localStorage.getItem("draft")).toBeNull();
|
||||
expect(sessionStorage.getItem("draft")).toBeNull();
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("falls back to known IndexedDB names when database enumeration is unavailable", async () => {
|
||||
const mocks = installBrowserMaintenanceMocks();
|
||||
delete mocks.indexedDB.databases;
|
||||
|
||||
const result = await forceFrontendUpdateAndClearLocal({ reload: vi.fn() });
|
||||
|
||||
expect(result.indexedDatabaseNames).toEqual([
|
||||
"workbox-expiration",
|
||||
"workbox-background-sync",
|
||||
"pleno",
|
||||
"truckwash",
|
||||
]);
|
||||
expect(mocks.deletedIndexedDatabases).toEqual([
|
||||
"workbox-expiration",
|
||||
"workbox-background-sync",
|
||||
"pleno",
|
||||
"truckwash",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// @vitest-environment jsdom
|
||||
import { enableAutoUnmount, mount } from "@vue/test-utils";
|
||||
import { nextTick } from "vue";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import LocalDataResetDialog from "@/components/global/LocalDataResetDialog.vue";
|
||||
|
||||
enableAutoUnmount(afterEach);
|
||||
|
||||
const flush = async () => {
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
};
|
||||
|
||||
const clickBodyButton = async (testId) => {
|
||||
await flush();
|
||||
document.body.querySelector(`[data-testid='${testId}']`).click();
|
||||
await flush();
|
||||
};
|
||||
|
||||
describe("LocalDataResetDialog", () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("emits dismiss and model update when Nej is pressed", async () => {
|
||||
const wrapper = mount(LocalDataResetDialog, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
modelValue: true,
|
||||
},
|
||||
});
|
||||
|
||||
await clickBodyButton("local-data-reset-cancel");
|
||||
|
||||
expect(wrapper.emitted("dismiss")).toHaveLength(1);
|
||||
expect(wrapper.emitted("update:modelValue")).toEqual([[false]]);
|
||||
});
|
||||
|
||||
it("emits confirm when Ja, ryd alt is pressed", async () => {
|
||||
const wrapper = mount(LocalDataResetDialog, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
modelValue: true,
|
||||
},
|
||||
});
|
||||
|
||||
await clickBodyButton("local-data-reset-confirm");
|
||||
|
||||
expect(wrapper.emitted("confirm")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
// @vitest-environment jsdom
|
||||
import { enableAutoUnmount, mount } from "@vue/test-utils";
|
||||
import { nextTick } from "vue";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import MobileFooter from "@/components/viewport/page/footers/MobileFooter.vue";
|
||||
import { forceFrontendUpdateAndClearLocal } from "@/services/frontendMaintenance.js";
|
||||
|
||||
enableAutoUnmount(afterEach);
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const route = {
|
||||
path: "/user/wash/start",
|
||||
fullPath: "/user/wash/start",
|
||||
};
|
||||
|
||||
return {
|
||||
route,
|
||||
push: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("vue-router", () => ({
|
||||
useRouter: () => ({
|
||||
currentRoute: {
|
||||
value: mocks.route,
|
||||
},
|
||||
push: mocks.push,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/services/frontendMaintenance.js", () => ({
|
||||
forceFrontendUpdateAndClearLocal: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
vi.mock("buefy", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
const BButton = defineComponent({
|
||||
name: "MockBButton",
|
||||
inheritAttrs: false,
|
||||
setup(_props, { attrs, slots }) {
|
||||
return () => {
|
||||
const { type: _type, iconPack: _iconPack, expanded: _expanded, size: _size, ...buttonAttrs } = attrs;
|
||||
return h(
|
||||
"button",
|
||||
{
|
||||
...buttonAttrs,
|
||||
type: "button",
|
||||
disabled: Boolean(attrs.disabled),
|
||||
},
|
||||
slots.default ? slots.default() : []
|
||||
);
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const Passthrough = defineComponent({
|
||||
name: "MockPassthrough",
|
||||
setup(_props, { slots }) {
|
||||
return () => h("div", slots.default ? slots.default() : []);
|
||||
},
|
||||
});
|
||||
|
||||
const BIcon = defineComponent({
|
||||
name: "MockBIcon",
|
||||
props: {
|
||||
icon: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
return () => h("span", { "data-testid": `mock-icon-${props.icon}` });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
BButton,
|
||||
BField: Passthrough,
|
||||
BIcon,
|
||||
};
|
||||
});
|
||||
|
||||
const flushMicrotasks = async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
};
|
||||
|
||||
const mountFooter = () =>
|
||||
mount(MobileFooter, {
|
||||
attachTo: document.body,
|
||||
});
|
||||
|
||||
const getHomeButton = (wrapper) => wrapper.get("[data-testid='mobile-footer-home']");
|
||||
|
||||
const dispatchHomePointerEvent = async (wrapper, eventName) => {
|
||||
const event = new Event(eventName, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
Object.defineProperty(event, "button", {
|
||||
value: 0,
|
||||
});
|
||||
Object.defineProperty(event, "pointerType", {
|
||||
value: "touch",
|
||||
});
|
||||
getHomeButton(wrapper).element.dispatchEvent(event);
|
||||
await nextTick();
|
||||
};
|
||||
|
||||
const clickDialogButton = async (testId) => {
|
||||
await flushMicrotasks();
|
||||
document.body.querySelector(`[data-testid='${testId}']`).click();
|
||||
await flushMicrotasks();
|
||||
};
|
||||
|
||||
const holdHomeFor = async (wrapper, durationMs) => {
|
||||
await dispatchHomePointerEvent(wrapper, "pointerdown");
|
||||
await vi.advanceTimersByTimeAsync(durationMs);
|
||||
await nextTick();
|
||||
};
|
||||
|
||||
describe("MobileFooter", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
document.body.innerHTML = "";
|
||||
mocks.route.path = "/user/wash/start";
|
||||
mocks.route.fullPath = "/user/wash/start";
|
||||
mocks.push.mockReset();
|
||||
vi.mocked(forceFrontendUpdateAndClearLocal).mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("navigates to /user on a short Hjem click", async () => {
|
||||
const wrapper = mountFooter();
|
||||
|
||||
await dispatchHomePointerEvent(wrapper, "pointerdown");
|
||||
await dispatchHomePointerEvent(wrapper, "pointerup");
|
||||
await getHomeButton(wrapper).trigger("click");
|
||||
|
||||
expect(mocks.push).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.push).toHaveBeenCalledWith("/user");
|
||||
expect(document.body.querySelector("[data-testid='local-data-reset-dialog']")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not open the reset dialog when Hjem is held for less than 5 seconds", async () => {
|
||||
const wrapper = mountFooter();
|
||||
|
||||
await holdHomeFor(wrapper, 4999);
|
||||
await dispatchHomePointerEvent(wrapper, "pointerup");
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
expect(document.body.querySelector("[data-testid='local-data-reset-dialog']")).toBeNull();
|
||||
});
|
||||
|
||||
it("opens the reset dialog after a 5 second Hjem hold and suppresses navigation", async () => {
|
||||
const wrapper = mountFooter();
|
||||
|
||||
await holdHomeFor(wrapper, 5000);
|
||||
await dispatchHomePointerEvent(wrapper, "pointerup");
|
||||
await getHomeButton(wrapper).trigger("click");
|
||||
|
||||
expect(document.body.querySelector("[data-testid='local-data-reset-dialog']")).not.toBeNull();
|
||||
expect(mocks.push).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes the reset dialog without cleanup when Nej is pressed", async () => {
|
||||
const wrapper = mountFooter();
|
||||
|
||||
await holdHomeFor(wrapper, 5000);
|
||||
await flushMicrotasks();
|
||||
await clickDialogButton("local-data-reset-cancel");
|
||||
|
||||
expect(document.body.querySelector("[data-testid='local-data-reset-dialog']")).toBeNull();
|
||||
expect(forceFrontendUpdateAndClearLocal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs the full local data cleanup when Ja, ryd alt is pressed", async () => {
|
||||
const wrapper = mountFooter();
|
||||
|
||||
await holdHomeFor(wrapper, 5000);
|
||||
await flushMicrotasks();
|
||||
await clickDialogButton("local-data-reset-confirm");
|
||||
|
||||
expect(forceFrontendUpdateAndClearLocal).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { flushPromises } from "@vue/test-utils";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ref } from "vue";
|
||||
import { mountWithApp } from "./helpers/mountWithApp.js";
|
||||
import PosDepartmentStepMobile1 from "@/components/displays/department/pos/steps/mobile/PosDepartmentStepMobile1.vue";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
request: vi.fn(),
|
||||
setTransparency: vi.fn(),
|
||||
setBackgroundColor: vi.fn(),
|
||||
setOverflow: vi.fn(),
|
||||
cameraSetLatestImage: vi.fn(),
|
||||
cameraSetLastSuccess: vi.fn(),
|
||||
cameraHasDelayAfterSuccessPassed: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/viewport/page/headers/ViewportHeaderSettings.vue", () => ({
|
||||
backgroundColors: {
|
||||
default: "default",
|
||||
},
|
||||
setBackgroundColor: mocks.setBackgroundColor,
|
||||
setOverflow: mocks.setOverflow,
|
||||
setTransparency: mocks.setTransparency,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/session/token/SessionUser.vue", () => ({
|
||||
SessionUser: {
|
||||
request: mocks.request,
|
||||
objects: {
|
||||
global: {
|
||||
language: {
|
||||
scanning: "Scanning",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue", () => {
|
||||
const manualInput = ref(false);
|
||||
const transactionHistoryView = ref(false);
|
||||
const activeVehicleIndex = ref(1);
|
||||
const activeVehicle = ref({ reg: "" });
|
||||
const latestImage = ref(null);
|
||||
|
||||
return {
|
||||
attachments: {
|
||||
base64: ref([]),
|
||||
},
|
||||
camera: {
|
||||
latestImage,
|
||||
setLatestImage: mocks.cameraSetLatestImage,
|
||||
hasDelayAfterSuccessPassed: mocks.cameraHasDelayAfterSuccessPassed,
|
||||
setLastSuccess: mocks.cameraSetLastSuccess,
|
||||
},
|
||||
manualInput,
|
||||
sounds: {
|
||||
list: ref({ onAfterSuccessfulScan: "scan" }),
|
||||
play: vi.fn(),
|
||||
},
|
||||
transactionHistoryView,
|
||||
vehicles: {
|
||||
activeVehicleIndex,
|
||||
getActiveVehicle: () => activeVehicle.value,
|
||||
select: vi.fn(),
|
||||
setActiveVehicleIndex: vi.fn((nextIndex) => {
|
||||
activeVehicleIndex.value = nextIndex;
|
||||
}),
|
||||
vehicle_1: activeVehicle,
|
||||
vehicle_2: ref({ reg: "" }),
|
||||
vehicle_3: ref({ reg: "" }),
|
||||
},
|
||||
views: {
|
||||
attachmentView: ref(false),
|
||||
isAnyActive: ref(false),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/viewport/page/templates/scanner/graphics/ScannerCamera.vue", () => ({
|
||||
default: {
|
||||
name: "ScannerCamera",
|
||||
emits: ["update:frame"],
|
||||
template: `
|
||||
<div>
|
||||
<button data-testid="camera-frame-a" @click="$emit('update:frame', 'frame-a')" />
|
||||
<button data-testid="camera-frame-b" @click="$emit('update:frame', 'frame-b')" />
|
||||
<button data-testid="camera-frame-c" @click="$emit('update:frame', 'frame-c')" />
|
||||
</div>
|
||||
`,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/viewport/page/templates/scanner/graphics/ScannerInstructions.vue", () => ({
|
||||
default: { template: "<div />" },
|
||||
}));
|
||||
|
||||
vi.mock("@/components/viewport/page/templates/scanner/graphics/ScannerOutline.vue", () => ({
|
||||
default: { template: "<div />" },
|
||||
}));
|
||||
|
||||
vi.mock("@/components/models/pos/step1/RegistrationNumberSearchResult.vue", () => ({
|
||||
default: { template: "<div />" },
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
"@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1RegistrationNumbers.vue",
|
||||
() => ({
|
||||
default: { template: "<div />" },
|
||||
})
|
||||
);
|
||||
|
||||
vi.mock("@/components/displays/department/pos/steps/mobile/views/PosDepartmentStep1MobileManualInput.vue", () => ({
|
||||
default: { template: "<div />" },
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
"@/components/displays/department/pos/steps/mobile/views/PosDepartmentStep1MobileTransactionHistory.vue",
|
||||
() => ({
|
||||
default: { template: "<div />" },
|
||||
})
|
||||
);
|
||||
|
||||
vi.mock("@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonNextStep.vue", () => ({
|
||||
default: { template: "<div />" },
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
"@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileFixedBottomControl.vue",
|
||||
() => ({
|
||||
default: { template: "<div><slot /></div>" },
|
||||
})
|
||||
);
|
||||
|
||||
vi.mock("@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1Location.vue", () => ({
|
||||
default: { template: "<div />" },
|
||||
}));
|
||||
|
||||
vi.mock("@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1Debug.vue", () => ({
|
||||
default: { template: "<div />" },
|
||||
}));
|
||||
|
||||
vi.mock("@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileAttachments.vue", () => ({
|
||||
default: { template: "<div />" },
|
||||
}));
|
||||
|
||||
vi.mock("@/components/viewport/elements/icons/UnknownCustomer.vue", () => ({
|
||||
default: { template: "<span />" },
|
||||
}));
|
||||
|
||||
vi.mock("@/components/viewport/elements/icons/VerifiedCustomer.vue", () => ({
|
||||
default: { template: "<span />" },
|
||||
}));
|
||||
|
||||
vi.mock("@/components/viewport/elements/icons/BookedCustomer.vue", () => ({
|
||||
default: { template: "<span />" },
|
||||
}));
|
||||
|
||||
vi.mock("@/components/viewport/elements/icons/KnownCustomer.vue", () => ({
|
||||
default: { template: "<span />" },
|
||||
}));
|
||||
|
||||
vi.mock("@/components/viewport/elements/icons/CardPaymentCustomer.vue", () => ({
|
||||
default: { template: "<span />" },
|
||||
}));
|
||||
|
||||
const resolveImageImmediately = () => {
|
||||
class TestImage {
|
||||
onerror = null;
|
||||
|
||||
set src(_value) {
|
||||
this.onerror?.(new Error("skip image decode"));
|
||||
}
|
||||
}
|
||||
|
||||
vi.stubGlobal("Image", TestImage);
|
||||
};
|
||||
|
||||
describe("POS mobile camera LPR", () => {
|
||||
beforeEach(() => {
|
||||
mocks.request.mockReset();
|
||||
mocks.cameraSetLatestImage.mockReset();
|
||||
mocks.cameraSetLastSuccess.mockReset();
|
||||
mocks.cameraHasDelayAfterSuccessPassed.mockReset();
|
||||
mocks.cameraHasDelayAfterSuccessPassed.mockReturnValue(true);
|
||||
resolveImageImmediately();
|
||||
});
|
||||
|
||||
it("does not enqueue overlapping scanner requests while a camera frame is still being parsed", async () => {
|
||||
let resolveFirstRequest;
|
||||
mocks.request.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveFirstRequest = resolve;
|
||||
})
|
||||
);
|
||||
mocks.request.mockResolvedValue({ data: { success: false } });
|
||||
|
||||
const wrapper = mountWithApp(PosDepartmentStepMobile1);
|
||||
|
||||
await wrapper.get('[data-testid="camera-frame-a"]').trigger("click");
|
||||
await flushPromises();
|
||||
expect(mocks.request).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.request).toHaveBeenLastCalledWith("/modules/scanner/lpr", "POST", {
|
||||
base64_image: "frame-a",
|
||||
});
|
||||
|
||||
await wrapper.get('[data-testid="camera-frame-b"]').trigger("click");
|
||||
await flushPromises();
|
||||
expect(mocks.request).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolveFirstRequest({ data: { success: false } });
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.get('[data-testid="camera-frame-c"]').trigger("click");
|
||||
await flushPromises();
|
||||
expect(mocks.request).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.request).toHaveBeenLastCalledWith("/modules/scanner/lpr", "POST", {
|
||||
base64_image: "frame-c",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -49,6 +49,231 @@ describe("useSelfServeLogic", () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const makeRule = ({ id = 1, conditionId = 100, objectType = "question", objectId = 1, type = "IS_TRUE" } = {}) => ({
|
||||
id,
|
||||
condition_id: conditionId,
|
||||
object_type: objectType,
|
||||
object_id: objectId,
|
||||
type,
|
||||
name: `${type}-${id}`,
|
||||
});
|
||||
|
||||
const applyEvaluationState = (logic, { answers = {}, rules = [], tasks = [], conditions = [] } = {}) => {
|
||||
logic.answers.value = answers;
|
||||
logic.rules.value = rules;
|
||||
logic.tasks.value = tasks;
|
||||
logic.conditions.value = conditions;
|
||||
};
|
||||
|
||||
describe("condition evaluation engine", () => {
|
||||
it.each([
|
||||
["IS_TRUE", true, true],
|
||||
["IS_TRUE", false, false],
|
||||
["IS_TRUE", null, false],
|
||||
["IS_TRUE", undefined, false],
|
||||
["IS_FALSE", false, true],
|
||||
["IS_FALSE", true, false],
|
||||
["IS_FALSE", null, false],
|
||||
["IS_FALSE", undefined, false],
|
||||
["IS_TRUE_OR_NOT_SET", true, true],
|
||||
["IS_TRUE_OR_NOT_SET", false, false],
|
||||
["IS_TRUE_OR_NOT_SET", null, true],
|
||||
["IS_TRUE_OR_NOT_SET", undefined, true],
|
||||
["IS_FALSE_OR_NOT_SET", false, true],
|
||||
["IS_FALSE_OR_NOT_SET", true, false],
|
||||
["IS_FALSE_OR_NOT_SET", null, true],
|
||||
["IS_FALSE_OR_NOT_SET", undefined, true],
|
||||
["IS_SET", true, true],
|
||||
["IS_SET", false, true],
|
||||
["IS_SET", null, false],
|
||||
["IS_SET", undefined, false],
|
||||
["UNKNOWN_RULE", true, false],
|
||||
])("evaluates %s for %s answers", (type, answerValue, expected) => {
|
||||
const logic = useSelfServeLogic();
|
||||
const answers = {};
|
||||
if (answerValue !== undefined) {
|
||||
answers[1] = answerValue;
|
||||
}
|
||||
const rule = makeRule({ type });
|
||||
|
||||
applyEvaluationState(logic, {
|
||||
answers,
|
||||
rules: [rule],
|
||||
});
|
||||
|
||||
expect(logic.evaluateRule(rule)).toBe(expected);
|
||||
expect(logic.evaluateCondition(100)).toBe(expected);
|
||||
});
|
||||
|
||||
it("treats an empty condition id as satisfied", () => {
|
||||
const logic = useSelfServeLogic();
|
||||
|
||||
expect(logic.evaluateCondition(null)).toBe(true);
|
||||
expect(logic.evaluateCondition(0)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects conditions with no rules when evaluated directly", () => {
|
||||
const logic = useSelfServeLogic();
|
||||
|
||||
applyEvaluationState(logic, {
|
||||
conditions: [{ id: 100, name: "No rules" }],
|
||||
});
|
||||
|
||||
expect(logic.evaluateCondition(100)).toBe(false);
|
||||
expect(logic.evaluateCondition(999)).toBe(false);
|
||||
});
|
||||
|
||||
it("evaluates nested condition references", () => {
|
||||
const logic = useSelfServeLogic();
|
||||
applyEvaluationState(logic, {
|
||||
answers: { 1: true },
|
||||
rules: [
|
||||
makeRule({ id: 1, conditionId: 100, objectType: "condition", objectId: 200, type: "IS_TRUE" }),
|
||||
makeRule({ id: 2, conditionId: 200, objectId: 1, type: "IS_TRUE" }),
|
||||
],
|
||||
});
|
||||
|
||||
expect(
|
||||
logic.evaluateRule(makeRule({ conditionId: 100, objectType: "condition", objectId: 200, type: "IS_TRUE" }))
|
||||
).toBe(true);
|
||||
expect(logic.evaluateCondition(100)).toBe(true);
|
||||
});
|
||||
|
||||
it("detects direct condition cycles", () => {
|
||||
const logic = useSelfServeLogic();
|
||||
applyEvaluationState(logic, {
|
||||
rules: [makeRule({ id: 1, conditionId: 100, objectType: "condition", objectId: 100, type: "IS_TRUE" })],
|
||||
});
|
||||
|
||||
expect(logic.evaluateCondition(100)).toBe(false);
|
||||
});
|
||||
|
||||
it("detects cycles across nested condition references", () => {
|
||||
const logic = useSelfServeLogic();
|
||||
applyEvaluationState(logic, {
|
||||
rules: [
|
||||
makeRule({ id: 1, conditionId: 100, objectType: "condition", objectId: 200, type: "IS_TRUE" }),
|
||||
makeRule({ id: 2, conditionId: 200, objectType: "condition", objectId: 100, type: "IS_TRUE" }),
|
||||
],
|
||||
});
|
||||
|
||||
expect(logic.evaluateCondition(100)).toBe(false);
|
||||
expect(logic.evaluateCondition(200)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for IS_TRUE_OR_ANY_TRUE when the nested condition is true", () => {
|
||||
const logic = useSelfServeLogic();
|
||||
applyEvaluationState(logic, {
|
||||
answers: { 1: true },
|
||||
rules: [
|
||||
makeRule({ id: 1, conditionId: 100, objectType: "condition", objectId: 200, type: "IS_TRUE_OR_ANY_TRUE" }),
|
||||
makeRule({ id: 2, conditionId: 200, objectId: 1, type: "IS_TRUE" }),
|
||||
],
|
||||
});
|
||||
|
||||
expect(logic.evaluateCondition(100)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for IS_TRUE_OR_ANY_TRUE when any nested rule is true", () => {
|
||||
const logic = useSelfServeLogic();
|
||||
applyEvaluationState(logic, {
|
||||
answers: { 1: true, 2: false },
|
||||
rules: [
|
||||
makeRule({ id: 1, conditionId: 100, objectType: "condition", objectId: 200, type: "IS_TRUE_OR_ANY_TRUE" }),
|
||||
makeRule({ id: 2, conditionId: 200, objectId: 1, type: "IS_FALSE" }),
|
||||
makeRule({ id: 3, conditionId: 200, objectId: 2, type: "IS_FALSE" }),
|
||||
],
|
||||
});
|
||||
|
||||
expect(logic.evaluateCondition(200)).toBe(false);
|
||||
expect(logic.evaluateCondition(100)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for IS_TRUE_OR_ANY_TRUE when direct question answer is not true", () => {
|
||||
const logic = useSelfServeLogic();
|
||||
const rule = makeRule({ type: "IS_TRUE_OR_ANY_TRUE" });
|
||||
applyEvaluationState(logic, {
|
||||
answers: { 1: false },
|
||||
rules: [rule],
|
||||
});
|
||||
|
||||
expect(logic.evaluateRule(rule)).toBe(false);
|
||||
expect(logic.evaluateCondition(100)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for IS_TRUE_OR_ANY_TRUE when nested rules are all false or missing", () => {
|
||||
const logic = useSelfServeLogic();
|
||||
applyEvaluationState(logic, {
|
||||
answers: { 1: true },
|
||||
rules: [
|
||||
makeRule({ id: 1, conditionId: 100, objectType: "condition", objectId: 200, type: "IS_TRUE_OR_ANY_TRUE" }),
|
||||
makeRule({ id: 2, conditionId: 200, objectId: 1, type: "IS_FALSE" }),
|
||||
makeRule({ id: 3, conditionId: 300, objectType: "condition", objectId: 999, type: "IS_TRUE_OR_ANY_TRUE" }),
|
||||
],
|
||||
});
|
||||
|
||||
expect(logic.evaluateCondition(100)).toBe(false);
|
||||
expect(logic.evaluateCondition(300)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps tasks with no condition or missing condition rules active by default", () => {
|
||||
const logic = useSelfServeLogic();
|
||||
applyEvaluationState(logic, {
|
||||
tasks: [
|
||||
{ id: 1, task_id: 1, task: "No condition", order_priority: 3, condition_id: null },
|
||||
{ id: 2, task_id: 2, task: "Zero condition", order_priority: 2, condition_id: 0 },
|
||||
{ id: 3, task_id: 3, task: "Missing rules", order_priority: 1, condition_id: 999 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(logic.activeTasks.value.map((task) => task.id)).toEqual([3, 2, 1]);
|
||||
expect(logic.isTaskActive(999)).toBe(false);
|
||||
});
|
||||
|
||||
it("filters and sorts active tasks based on evaluated conditions", () => {
|
||||
const logic = useSelfServeLogic();
|
||||
applyEvaluationState(logic, {
|
||||
answers: { 1: true, 2: false, 3: null },
|
||||
rules: [
|
||||
makeRule({ id: 1, conditionId: 100, objectId: 1, type: "IS_TRUE" }),
|
||||
makeRule({ id: 2, conditionId: 200, objectId: 2, type: "IS_TRUE" }),
|
||||
makeRule({ id: 3, conditionId: 300, objectId: 3, type: "IS_TRUE_OR_NOT_SET" }),
|
||||
],
|
||||
tasks: [
|
||||
{
|
||||
id: 1,
|
||||
task_id: 1,
|
||||
task: "Hidden false condition",
|
||||
order_priority: 1,
|
||||
condition_id: 200,
|
||||
services: ["HIDDEN"],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
task_id: 2,
|
||||
task: "Active null condition",
|
||||
order_priority: 3,
|
||||
condition_id: 300,
|
||||
services: ["OPTIONAL"],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
task_id: 3,
|
||||
task: "Active true condition",
|
||||
order_priority: 2,
|
||||
condition_id: 100,
|
||||
services: ["MACHINE"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(logic.activeTasks.value.map((task) => task.id)).toEqual([3, 2]);
|
||||
expect(logic.isTaskActive(1)).toBe(false);
|
||||
expect(logic.isTaskActive(2)).toBe(true);
|
||||
expect(logic.activeTaskServices.value).toEqual(["MACHINE", "OPTIONAL"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("uses task attachments supplied by preview and summary data", async () => {
|
||||
const taskAttachments = [
|
||||
{ id: 201, content: { other: "manual.pdf" }, download_link: "https://cdn.example.test/manual.pdf" },
|
||||
|
||||
Reference in New Issue
Block a user