Implement POS registration field and autosave utility:

- Added `PosOrderRegistrationField.vue` for managing inline registration field inputs with autosave behavior.
- Introduced `useOrderMetadataAutosave.js` composable for seamless value normalization, dirty tracking, and delayed persistence.
- Refactored mobile and desktop POS components to integrate `useOrderMetadataAutosave` for improved field management (e.g., `POSOrderReference.vue`, `PosOrderLicensePlates.vue`).
- Updated e2e tests to cover registration field scenarios, including input normalization and persistence (`pos-mobile-order-flow.spec.js`, `admin-pos-orders.spec.ts`).
- Enhanced styling and responsiveness for registration fields in `pos.css`.
This commit is contained in:
Jeppe Bundgaard
2026-04-13 14:20:36 +02:00
parent ade565cbca
commit 295f69ee41
11 changed files with 855 additions and 191 deletions
+24
View File
@@ -383,6 +383,11 @@
transition: border-color 120ms ease, box-shadow 120ms ease, transform 120ms ease;
}
.pos-registration-field--editing {
justify-content: center;
cursor: text;
}
.pos-registration-field:hover,
.pos-registration-field:focus-visible {
border-color: #93b3d8;
@@ -428,6 +433,25 @@
color: #63768f;
}
.pos-registration-field__input {
width: 100%;
min-width: 0;
padding: 0;
border: 0;
box-shadow: none;
background: transparent;
color: inherit;
font-size: 0.95rem;
font-weight: 600;
line-height: 1.35;
text-transform: uppercase;
}
.pos-registration-field__input:focus {
outline: none;
box-shadow: none;
}
.pos-order-items--order-detail .pos-order-metadata-grid {
gap: 0.9rem;
grid-template-columns: minmax(20rem, 1.22fr) minmax(16rem, 1fr);
@@ -1,7 +1,8 @@
<script setup>
import { computed, defineProps, nextTick, onBeforeUnmount, ref, watch } from 'vue';
import { computed, defineProps, nextTick, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { useOrderMetadataAutosave } from "@/composables/useOrderMetadataAutosave.js";
const props = defineProps({
order_id: {
@@ -21,26 +22,23 @@ const props = defineProps({
const { t } = useI18n();
const uniqueId = Math.random().toString(36).slice(2);
const input = ref(props.note ?? '');
const lastSavedValue = ref(props.note ?? '');
const isEditing = ref(false);
const isSaving = ref(false);
const autosave = useOrderMetadataAutosave({
source: () => props.note,
saveValue: async (value) => {
await SessionUser.objects.orders.set.notes(props.order_id, value);
return value;
},
});
const isDirty = computed(() => input.value !== lastSavedValue.value);
const previewValue = computed(() => input.value);
const rowCount = computed(() => Math.max(input.value.split('\n').length, 5));
const previewValue = computed(() => autosave.draft.value);
const rowCount = computed(() => Math.max((autosave.draft.value || '').split('\n').length, 5));
const syncFromProps = (value) => {
const normalizedValue = value ?? '';
const shouldHydrateInput = !isEditing.value || input.value === lastSavedValue.value;
lastSavedValue.value = normalizedValue;
if (shouldHydrateInput) {
input.value = normalizedValue;
watch(() => autosave.draft.value, () => {
if (isEditing.value) {
autosave.scheduleSave();
}
};
watch(() => props.note, syncFromProps, { immediate: true });
});
const focusOnTextarea = () => {
document.getElementById(`${uniqueId}-textarea`)?.focus();
@@ -57,51 +55,20 @@ const onFieldClicked = () => {
});
};
const saveChanges = async ({ force = false } = {}) => {
if ((!isDirty.value && !force) || isSaving.value) {
return;
}
isSaving.value = true;
try {
await SessionUser.request('/order', 'PUT', {
id: props.order_id,
field: 'notes',
value: input.value
});
lastSavedValue.value = input.value;
await props.loadOrder();
} catch (error) {
console.error(error);
} finally {
isSaving.value = false;
}
};
const lostFocus = () => {
const lostFocus = async () => {
isEditing.value = false;
void saveChanges({ force: true });
await autosave.flush();
};
const autoSaveInterval = window.setInterval(() => {
if (isEditing.value && isDirty.value) {
void saveChanges();
}
}, 2000);
onBeforeUnmount(() => {
window.clearInterval(autoSaveInterval);
});
</script>
<template>
<div class="pos-order-field pos-order-note-editor">
<div class="control" :class="{ 'is-loading': isSaving }">
<div class="control" :class="{ 'is-loading': autosave.isSaving.value }">
<div v-if="isEditing" class="pos-order-field__control pos-order-field__control--editing">
<textarea
:id="`${uniqueId}-textarea`"
v-model="input"
v-model="autosave.draft.value"
data-testid="pos-order-note-textarea"
class="textarea pos-order-field__input"
:rows="rowCount"
:placeholder="t('pos.order.order_note')"
@@ -111,7 +78,7 @@ onBeforeUnmount(() => {
<button
v-else
class="pos-order-field__control pos-order-field__control--interactive"
:class="{ 'pos-order-field__control--empty': !input }"
:class="{ 'pos-order-field__control--empty': !autosave.draft.value }"
type="button"
@click="onFieldClicked()"
>
@@ -1,7 +1,8 @@
<script setup>
import { computed, defineProps, nextTick, onBeforeUnmount, ref, watch } from 'vue';
import { computed, defineProps, nextTick, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { useOrderMetadataAutosave } from "@/composables/useOrderMetadataAutosave.js";
const props = defineProps({
order_id: {
@@ -21,26 +22,23 @@ const props = defineProps({
const { t } = useI18n();
const uniqueId = Math.random().toString(36).slice(2);
const input = ref(props.reference ?? '');
const lastSavedValue = ref(props.reference ?? '');
const isEditing = ref(false);
const isSaving = ref(false);
const autosave = useOrderMetadataAutosave({
source: () => props.reference,
saveValue: async (value) => {
await SessionUser.objects.orders.set.reference(props.order_id, value);
return value;
},
});
const isDirty = computed(() => input.value !== lastSavedValue.value);
const previewValue = computed(() => input.value);
const rowCount = computed(() => Math.max(input.value.split('\n').length, 1));
const previewValue = computed(() => autosave.draft.value);
const rowCount = computed(() => Math.max((autosave.draft.value || '').split('\n').length, 1));
const syncFromProps = (value) => {
const normalizedValue = value ?? '';
const shouldHydrateInput = !isEditing.value || input.value === lastSavedValue.value;
lastSavedValue.value = normalizedValue;
if (shouldHydrateInput) {
input.value = normalizedValue;
watch(() => autosave.draft.value, () => {
if (isEditing.value) {
autosave.scheduleSave();
}
};
watch(() => props.reference, syncFromProps, { immediate: true });
});
const focusOnTextarea = () => {
document.getElementById(`${uniqueId}-textarea`)?.focus();
@@ -57,51 +55,20 @@ const onFieldClicked = () => {
});
};
const saveChanges = async ({ force = false } = {}) => {
if ((!isDirty.value && !force) || isSaving.value) {
return;
}
isSaving.value = true;
try {
await SessionUser.request('/order', 'PUT', {
id: props.order_id,
field: 'reference',
value: input.value
});
lastSavedValue.value = input.value;
await props.loadOrder();
} catch (error) {
console.error(error);
} finally {
isSaving.value = false;
}
};
const lostFocus = () => {
const lostFocus = async () => {
isEditing.value = false;
void saveChanges({ force: true });
await autosave.flush();
};
const autoSaveInterval = window.setInterval(() => {
if (isEditing.value && isDirty.value) {
void saveChanges();
}
}, 2000);
onBeforeUnmount(() => {
window.clearInterval(autoSaveInterval);
});
</script>
<template>
<div class="pos-order-field pos-order-reference-editor">
<div class="control" :class="{ 'is-loading': isSaving }">
<div class="control" :class="{ 'is-loading': autosave.isSaving.value }">
<div v-if="isEditing" class="pos-order-field__control pos-order-field__control--editing">
<textarea
:id="`${uniqueId}-textarea`"
v-model="input"
v-model="autosave.draft.value"
data-testid="pos-order-reference-textarea"
class="textarea pos-order-field__input"
:rows="rowCount"
:placeholder="t('pos.order.reference')"
@@ -111,7 +78,7 @@ onBeforeUnmount(() => {
<button
v-else
class="pos-order-field__control pos-order-field__control--interactive"
:class="{ 'pos-order-field__control--empty': !input }"
:class="{ 'pos-order-field__control--empty': !autosave.draft.value }"
type="button"
@click="onFieldClicked()"
>
@@ -1,8 +1,7 @@
<script setup>
import SpanSkeleton from "@/components/displays/skeletons/SpanSkeleton.vue";
import { computed, defineProps } from "vue";
import { useI18n } from 'vue-i18n';
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import PosOrderRegistrationField from "@/components/displays/department/pos/order/PosOrderRegistrationField.vue";
const props = defineProps({
registration_numbers: {
@@ -22,8 +21,6 @@ const props = defineProps({
default: false,
},
});
const { t } = useI18n();
const registrationEntries = computed(() => {
return [1, 2, 3].map((index) => ({
index,
@@ -31,17 +28,6 @@ const registrationEntries = computed(() => {
}));
});
const openRegistrationEditor = (index, value) => {
return SessionUser.editField.showEditFieldForm(
'/order',
props.order_id,
`reg_${index}`,
value,
`Reg ${index}`,
props.loadOrder
).then(() => props.loadOrder());
};
// Check if the required props are passed
if (!props.registration_numbers) {
throw new Error("Missing required prop: registration_numbers");
@@ -65,27 +51,13 @@ if (!props.loadOrder) {
height="3rem"
skeleton-class="is-size-6"
/>
<button
v-else-if="entry.value"
class="pos-registration-field"
type="button"
:data-testid="`pos-order-registration-${entry.index}`"
@click="openRegistrationEditor(entry.index, entry.value)"
>
<span class="pos-registration-field__label">{{ t('pos.order.reg') }} {{ entry.index }}</span>
<span class="pos-registration-field__value">{{ entry.value }}</span>
</button>
<button
<PosOrderRegistrationField
v-else
class="pos-registration-field pos-registration-field--add"
type="button"
:data-testid="`pos-order-registration-add-${entry.index}`"
:aria-label="`${t('common.add')} ${t('pos.order.reg')} ${entry.index}`"
@click="openRegistrationEditor(entry.index, '')"
>
<span class="pos-registration-field__label">{{ t('pos.order.reg') }} {{ entry.index }}</span>
<span class="pos-registration-field__value pos-registration-field__value--muted">+ {{ t('common.add') }}</span>
</button>
:index="entry.index"
:order_id="props.order_id"
:modelValue="entry.value"
:loadOrder="props.loadOrder"
/>
</div>
</template>
</div>
@@ -0,0 +1,116 @@
<script setup>
import { computed, defineProps, nextTick, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { useOrderMetadataAutosave } from "@/composables/useOrderMetadataAutosave.js";
const props = defineProps({
index: {
type: Number,
required: true,
},
order_id: {
type: [Number, String],
required: true,
},
modelValue: {
type: String,
default: "",
},
loadOrder: {
type: Function,
required: true,
},
});
const { t } = useI18n();
const normalizeRegistrationDraft = (value) => String(value ?? "").toUpperCase();
const inputId = `pos-order-registration-input-${props.index}`;
const isEditing = ref(false);
const fieldKey = computed(() => `reg_${props.index}`);
let autosave;
autosave = useOrderMetadataAutosave({
source: () => props.modelValue,
normalizeValue: normalizeRegistrationDraft,
saveValue: async (value) => {
await SessionUser.objects.orders.set[fieldKey.value](props.order_id, value);
return value;
},
onSaved: async () => {
await props.loadOrder();
await nextTick();
autosave.syncFromSource(props.modelValue);
},
});
const hasValue = computed(() => autosave.draft.value.length > 0);
const buttonTestId = computed(() => (hasValue.value ? `pos-order-registration-${props.index}` : `pos-order-registration-add-${props.index}`));
watch(() => autosave.draft.value, () => {
if (isEditing.value) {
autosave.scheduleSave();
}
});
const focusInput = () => {
document.getElementById(inputId)?.focus();
};
const openEditor = () => {
if (isEditing.value) {
return;
}
isEditing.value = true;
nextTick(() => {
focusInput();
});
};
const closeEditor = async () => {
isEditing.value = false;
await autosave.flush();
};
</script>
<template>
<div class="control pos-registration-control" :class="{ 'is-loading': autosave.isSaving.value }">
<div v-if="isEditing" class="pos-registration-field pos-registration-field--editing">
<label class="pos-registration-field__label" :for="inputId">{{ t('pos.order.reg') }} {{ props.index }}</label>
<input
:id="inputId"
v-model="autosave.draft.value"
:data-testid="`pos-order-registration-input-${props.index}`"
class="pos-registration-field__input"
type="text"
autocomplete="off"
@blur="closeEditor()"
@keydown.enter.prevent="closeEditor()"
/>
</div>
<button
v-else
class="pos-registration-field"
:class="{ 'pos-registration-field--add': !hasValue }"
type="button"
:data-testid="buttonTestId"
:aria-label="!hasValue ? `${t('common.add')} ${t('pos.order.reg')} ${props.index}` : undefined"
@click="openEditor()"
>
<span class="pos-registration-field__label">{{ t('pos.order.reg') }} {{ props.index }}</span>
<span
class="pos-registration-field__value"
:class="{ 'pos-registration-field__value--muted': !hasValue }"
>
{{ hasValue ? autosave.draft.value : `+ ${t('common.add')}` }}
</span>
</button>
</div>
</template>
<style scoped>
</style>
@@ -19,7 +19,7 @@ import PosDepartmentStep2MobileVehicleSelection
import PosDepartmentStepMobileButtonNextStep
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonNextStep.vue";
import { primaryItem } from "./objects/PosDepartmentStepMobileFlow.vue";
import { order_id, order_notes, department_id, customer_id, getCustomerEmail, customer_name, isAddonRestricted, canBuyAdditionalServices } from "@/components/shop/POSDepartmentProcess.vue";
import { order_id, order_notes, reference as persistedReference, reg_1, reg_2, reg_3, department_id, customer_id, getCustomerEmail, customer_name, isAddonRestricted, canBuyAdditionalServices } from "@/components/shop/POSDepartmentProcess.vue";
import { createOrderItem, getOrderItems, removeOrderItem } from "@/components/shop/OrdersItems.vue";
import { PosProduct } from "@/components/displays/department/pos/steps/mobile/objects/PosProduct.vue";
import PosDepartmentStepMobileButtonClearAll
@@ -32,6 +32,7 @@ import PosDepartmentStepMobile2AdditionalItems
import PosDepartmentStepMobile2Customer
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2Customer.vue";
import { pendingBookings, getVehiclePlateBooking } from "@/components/shop/POSDepartmentProcess.vue";
import { useOrderMetadataAutosave } from "@/composables/useOrderMetadataAutosave.js";
onMounted(() => {
@@ -39,7 +40,9 @@ onMounted(() => {
setTransparency(false);
setBackgroundColor(backgroundColors.default); // Set the default background color
// Set the reference to the vehicle 1 reference if it's not already set
if (vehicles?.vehicle_1?.value?.reference && !reference.value) {
if (persistedReference.value && !reference.value) {
reference.value = persistedReference.value;
} else if (vehicles?.vehicle_1?.value?.reference && !reference.value) {
reference.value = vehicles.vehicle_1.value.reference;
}
// Set the notes to the order notes if it's not already set
@@ -426,6 +429,102 @@ const getNormalizedOrderId = () => {
return Number.isInteger(parsedOrderId) && parsedOrderId > 0 ? parsedOrderId : null;
}
const normalizeRegistrationValue = (value: string | null | undefined) => String(value ?? "").trim().toUpperCase().replace(/[^A-Z0-9]/g, "");
const syncVehicleRegistrationFromOrder = (vehicleIndex: number, value: string | null | undefined) => {
const normalizedValue = normalizeRegistrationValue(value);
if (!normalizedValue) {
return;
}
const currentVehicle = vehicles.get(vehicleIndex);
if (currentVehicle?.reg === normalizedValue) {
return;
}
vehicles.select(vehicleIndex, {
...(currentVehicle ?? {}),
reg: normalizedValue,
});
};
const notesAutosave = useOrderMetadataAutosave({
source: order_notes,
saveValue: async (value) => {
const normalizedOrderId = getNormalizedOrderId();
if (!normalizedOrderId) {
return value;
}
await SessionUser.objects.orders.set.notes(normalizedOrderId, value);
return value;
},
onSaved: async (value) => {
order_notes.value = value;
metadata.setNotes(value);
},
});
const referenceAutosave = useOrderMetadataAutosave({
source: persistedReference,
saveValue: async (value) => {
const normalizedOrderId = getNormalizedOrderId();
if (!normalizedOrderId) {
return value;
}
await SessionUser.objects.orders.set.reference(normalizedOrderId, value);
return value;
},
onSaved: async (value) => {
persistedReference.value = value;
metadata.setReference(value);
},
});
const stepTwoNotesInput = notesAutosave.draft;
const stepTwoReferenceInput = referenceAutosave.draft;
watch(stepTwoNotesInput, (value) => {
metadata.setNotes(value);
if (getNormalizedOrderId()) {
notesAutosave.scheduleSave();
}
});
watch(stepTwoReferenceInput, (value) => {
metadata.setReference(value);
if (getNormalizedOrderId()) {
referenceAutosave.scheduleSave();
}
});
watch(() => getNormalizedOrderId(), (normalizedOrderId) => {
if (!normalizedOrderId) {
return;
}
if (notesAutosave.isDirty.value) {
notesAutosave.scheduleSave();
}
if (referenceAutosave.isDirty.value) {
referenceAutosave.scheduleSave();
}
});
watch([reg_1, reg_2, reg_3], ([nextReg1, nextReg2, nextReg3]) => {
if (!getNormalizedOrderId()) {
return;
}
syncVehicleRegistrationFromOrder(1, nextReg1);
syncVehicleRegistrationFromOrder(2, nextReg2);
syncVehicleRegistrationFromOrder(3, nextReg3);
}, { immediate: true });
const normalizeOrderItemShape = (item: any) => ({
product_id: Number(item?.product_id ?? item?.product?.id ?? 0),
quantity: Number(item?.quantity ?? 0),
@@ -715,12 +814,28 @@ const filteredAddons = computed(() => {
<!-- Notes -->
<ControlField>
<ControlFieldInputLabel label="Notes" :classes="layout.classes" :optional="true"/>
<ControlFieldInput testId="pos-mobile-notes-input" type="text" placeholder="" :classes="layout.classes" :vmodel="order_notes" @change="metadata.setNotes($event); SessionUser.objects.orders.set.notes(order_id, $event);"/>
<ControlFieldInput
testId="pos-mobile-notes-input"
type="text"
placeholder=""
:classes="layout.classes"
:vmodel="stepTwoNotesInput"
@input="stepTwoNotesInput = $event"
@change="notesAutosave.flush()"
/>
</ControlField>
<!-- Reference -->
<ControlField>
<ControlFieldInputLabel label="Reference" :classes="layout.classes" :optional="true"/> <!-- TODO: Make this required, if the customer requires it -->
<ControlFieldInput testId="pos-mobile-reference-step-2-input" type="text" placeholder="" :classes="layout.classes" :vmodel="reference" @change="metadata.setReference($event); SessionUser.objects.orders.set.reference(order_id, $event);"/>
<ControlFieldInput
testId="pos-mobile-reference-step-2-input"
type="text"
placeholder=""
:classes="layout.classes"
:vmodel="stepTwoReferenceInput"
@input="stepTwoReferenceInput = $event"
@change="referenceAutosave.flush()"
/>
</ControlField>
<PosDepartmentStepMobileFixedBottomControl variant="pos-step" :reserveSpace="false">
<!-- Complete button -->
@@ -1,19 +1,18 @@
<script setup lang="ts">
import {ref, defineEmits, onMounted, watch, defineProps} from "vue";
import { vehicles } from "../objects/PosDepartmentStepMobileFlow.vue";
import { reg_1, reg_2, reg_3, vehicles_matching } from "@/components/shop/POSDepartmentProcess.vue";
import {ref, defineEmits, onBeforeUnmount, onMounted, watch, defineProps} from "vue";
import { order_id, reg_1, reg_2, reg_3, getOrderDetails } from "@/components/shop/POSDepartmentProcess.vue";
import PosDepartmentStepMobile1RegistrationNumberInputField from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1RegistrationNumberInputField.vue";
import BarcodeScanner from "@/components/viewport/elements/icons/BarcodeScanner.vue";
import GenericButton from "@/components/viewport/page/templates/generic/graphics/GenericButton.vue";
import RegistrationNumberSearchResult from "@/components/models/pos/step1/RegistrationNumberSearchResult.vue";
import VerifiedCustomer from "@/components/viewport/elements/icons/VerifiedCustomer.vue";
import { pos } from "../objects/PosDepartmentStepMobileFlow.vue";
import { PosVehicle } from "@/components/displays/department/pos/steps/mobile/objects/PosVehicle.vue";
import SessionUser from "@/components/session/token/SessionUser.vue";
import PosDepartmentStepMobileFixedBottomControl
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileFixedBottomControl.vue";
import PosDepartmentStepMobileButtonNextStep
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonNextStep.vue";
import { useOrderMetadataAutosave } from "@/composables/useOrderMetadataAutosave.js";
// Define the close event to emit when the component is closed
const emit = defineEmits(["close"]);
// Define the props
@@ -48,16 +47,150 @@ const inputTestIds = {
reg3: 'pos-mobile-reg-input-3',
};
const normalizeRegistrationValue = (value: string | null | undefined) => String(value ?? '').toUpperCase();
const getNormalizedOrderId = () => {
const parsedOrderId = Number.parseInt(String(order_id.value), 10);
return Number.isInteger(parsedOrderId) && parsedOrderId > 0 ? parsedOrderId : null;
};
const getRegistrationSourceRef = (vehicleIndex: number) => {
switch (vehicleIndex) {
case 1:
return reg_1;
case 2:
return reg_2;
case 3:
return reg_3;
default:
return reg_1;
}
};
const setPersistedRegistrationValue = (vehicleIndex: number, value: string) => {
const normalizedValue = normalizeRegistrationValue(value);
getRegistrationSourceRef(vehicleIndex).value = normalizedValue;
};
const syncVehicleRegistration = (vehicleIndex: number, value: string, overrides: Record<string, any> = {}) => {
const normalizedValue = normalizeRegistrationValue(value);
if (normalizedValue === "") {
pos.vehicles.select(vehicleIndex, null);
return;
}
const currentVehicle = pos.vehicles.get(vehicleIndex);
pos.vehicles.select(vehicleIndex, {
...(currentVehicle ?? {}),
...overrides,
reg: normalizedValue,
});
};
// Define a reactive variable to control the expanded state of the component
const expanded = ref(props.forceShowApplicable);
// Define reactive variables for registration numbers and labels
const registrationNumber1 = ref("");
const label1 = ref("Reg 1*");
const registrationNumber2 = ref("");
const label2 = ref("Reg 2*");
const registrationNumber3 = ref("");
const label3 = ref("Reg 3");
const createRegistrationAutosave = (vehicleIndex: number) => useOrderMetadataAutosave({
source: getRegistrationSourceRef(vehicleIndex),
normalizeValue: normalizeRegistrationValue,
saveValue: async (value) => {
const normalizedOrderId = getNormalizedOrderId();
if (!normalizedOrderId) {
return value;
}
await SessionUser.objects.orders.set[`reg_${vehicleIndex}`](normalizedOrderId, value);
const refreshedOrder = await getOrderDetails(normalizedOrderId);
return refreshedOrder?.[`reg_${vehicleIndex}`] ?? value;
},
onSaved: async (value) => {
setPersistedRegistrationValue(vehicleIndex, value);
syncVehicleRegistration(vehicleIndex, value);
},
});
const registrationAutosaves = {
1: createRegistrationAutosave(1),
2: createRegistrationAutosave(2),
3: createRegistrationAutosave(3),
};
const reg1Draft = registrationAutosaves[1].draft;
const reg2Draft = registrationAutosaves[2].draft;
const reg3Draft = registrationAutosaves[3].draft;
const setRegistrationDraft = (vehicleIndex: number, value: string) => {
const normalizedValue = normalizeRegistrationValue(value);
switch (vehicleIndex) {
case 1:
reg1Draft.value = normalizedValue;
break;
case 2:
reg2Draft.value = normalizedValue;
break;
case 3:
reg3Draft.value = normalizedValue;
break;
default:
break;
}
};
watch(reg1Draft, (value) => {
syncVehicleRegistration(1, value);
if (getNormalizedOrderId()) {
registrationAutosaves[1].scheduleSave();
return;
}
setPersistedRegistrationValue(1, value);
});
watch(reg2Draft, (value) => {
syncVehicleRegistration(2, value);
if (getNormalizedOrderId()) {
registrationAutosaves[2].scheduleSave();
return;
}
setPersistedRegistrationValue(2, value);
});
watch(reg3Draft, (value) => {
syncVehicleRegistration(3, value);
if (getNormalizedOrderId()) {
registrationAutosaves[3].scheduleSave();
return;
}
setPersistedRegistrationValue(3, value);
});
watch(() => getNormalizedOrderId(), (normalizedOrderId) => {
if (!normalizedOrderId) {
return;
}
[1, 2, 3].forEach((vehicleIndex) => {
if (registrationAutosaves[vehicleIndex].isDirty.value) {
registrationAutosaves[vehicleIndex].scheduleSave();
}
});
});
const flushRegistration = async (vehicleIndex: number) => {
await registrationAutosaves[vehicleIndex].flush();
};
const flushAllRegistrations = async () => {
await Promise.all([1, 2, 3].map((vehicleIndex) => registrationAutosaves[vehicleIndex].flush()));
};
// Function to set a value if it is not null
function setIfNotNull(variable: string, value: { registrationNumber: string, customerStatus: any, customerId?: number, lastOrderId?: number } | null) {
//console.warn("setIfNotNull", variable, value);
@@ -74,8 +207,7 @@ function setIfNotNull(variable: string, value: { registrationNumber: string, cus
last_order_id: value.lastOrderId,
}
)
registrationNumber1.value = value.registrationNumber;
reg_1.value = value.registrationNumber;
setRegistrationDraft(1, value.registrationNumber);
//reg_1_status.value = value.customerStatus;
break;
case 'reg_2':
@@ -87,8 +219,7 @@ function setIfNotNull(variable: string, value: { registrationNumber: string, cus
customer_id: value.customerId,
}
)
registrationNumber2.value = value.registrationNumber;
reg_2.value = value.registrationNumber;
setRegistrationDraft(2, value.registrationNumber);
//reg_2_status.value = value.customerStatus;
break;
@@ -101,8 +232,7 @@ function setIfNotNull(variable: string, value: { registrationNumber: string, cus
customer_id: value.customerId,
}
)
registrationNumber3.value = value.registrationNumber;
reg_3.value = value.registrationNumber;
setRegistrationDraft(3, value.registrationNumber);
//reg_3_status.value = value.customerStatus;
break;
}
@@ -118,31 +248,38 @@ function setIfNotNull(variable: string, value: { registrationNumber: string, cus
// - Automatically select the first vehicle in the list, if the user input matches one of the vehicles exactly
function setValueUserInput(vehicleIndex: number, value: string) {
//console.log("setValueUserInput", vehicleIndex, value);
let vehicle: PosVehicle | null = null; // Initialize vehicle as null
const normalizedValue = normalizeRegistrationValue(value);
/** Case: Empty input */
// If the value is empty, clear the registration number
if (value === "") {
pos.vehicles.select(vehicleIndex, vehicle);
if (normalizedValue === "") {
pos.vehicles.select(vehicleIndex, null);
setRegistrationDraft(vehicleIndex, '');
void flushRegistration(vehicleIndex);
return;
}
/** Case: Input matches a vehicle exactly */
// If the value matches a result exactly, select that vehicle
let matchingVehicle = vehicles_matching.value.filter(vehicle => vehicle.reg === value); // Filter the vehicles_matching array to find a vehicle with the same registration number
if (matchingVehicle.length > 0) {
//console.log("setValueUserInput: matchingVehicle", matchingVehicle);
}
setRegistrationDraft(vehicleIndex, normalizedValue);
void flushRegistration(vehicleIndex);
}
onMounted(() => {
// Initialize the registration numbers from the pos.vehicles object
reg_1.value = pos.vehicles.get(1)?.reg ?? "";
reg_2.value = pos.vehicles.get(2)?.reg ?? "";
reg_3.value = pos.vehicles.get(3)?.reg ?? "";
setRegistrationDraft(1, pos.vehicles.get(1)?.reg ?? reg_1.value ?? "");
setRegistrationDraft(2, pos.vehicles.get(2)?.reg ?? reg_2.value ?? "");
setRegistrationDraft(3, pos.vehicles.get(3)?.reg ?? reg_3.value ?? "");
// If the reg_3 is not empty, set the expanded state to true
expanded.value = reg_3.value !== "" || props.forceShowApplicable;
});
onBeforeUnmount(() => {
void flushAllRegistrations();
});
const closeManualInput = async () => {
await flushAllRegistrations();
emit('close');
};
</script>
<template>
@@ -150,52 +287,51 @@ onMounted(() => {
<template v-if="props.showReg1">
<!-- First input field with a required label -->
<PosDepartmentStepMobile1RegistrationNumberInputField
v-model:modelValue="reg_1"
v-model:modelValue="reg1Draft"
v-model:label="label1"
:inputTestId="inputTestIds.reg1"
@focusout="setValueUserInput(1, reg_1)"
@onInput="reg_1 = $event"
@focusout="setValueUserInput(1, reg1Draft)"
>
<template #searchResults>
<RegistrationNumberSearchResult @select="setIfNotNull('reg_1', $event)" :searchQuery="reg_1" :automaticallySelect="true"/>
<RegistrationNumberSearchResult @select="setIfNotNull('reg_1', $event)" :searchQuery="reg1Draft" :automaticallySelect="true"/>
</template>
</PosDepartmentStepMobile1RegistrationNumberInputField>
</template>
<template v-if="props.showReg2">
<!-- Second input field with a custom expander button -->
<PosDepartmentStepMobile1RegistrationNumberInputField
v-model:modelValue="reg_2"
v-model:modelValue="reg2Draft"
v-model:label="label2"
:inputTestId="inputTestIds.reg2"
@focusout="setValueUserInput(2, reg_2)"
@focusout="setValueUserInput(2, reg2Draft)"
>
<template #default>
<div class="custom-expander" @click="expanded = !expanded" v-if="!reg_3">
<div class="custom-expander" @click="expanded = !expanded" v-if="!reg3Draft">
<span class="custom-text">+</span>
</div>
</template>
<template #searchResults>
<RegistrationNumberSearchResult @select="setIfNotNull('reg_2', $event)" :searchQuery="reg_2" :modifyCustomerOnChange="false" :automaticallySelect="true"/>
<RegistrationNumberSearchResult @select="setIfNotNull('reg_2', $event)" :searchQuery="reg2Draft" :modifyCustomerOnChange="false" :automaticallySelect="true"/>
</template>
</PosDepartmentStepMobile1RegistrationNumberInputField>
</template>
<template v-if="props.showReg3">
<!-- Third input field that is conditionally displayed based on the expanded state -->
<PosDepartmentStepMobile1RegistrationNumberInputField
v-show="expanded || reg_3 !== ''"
v-model:modelValue="reg_3"
v-show="expanded || reg3Draft !== ''"
v-model:modelValue="reg3Draft"
v-model:label="label3"
:inputTestId="inputTestIds.reg3"
@focusout="setValueUserInput(3, reg_3)"
@focusout="setValueUserInput(3, reg3Draft)"
>
<template #searchResults>
<RegistrationNumberSearchResult @select="setIfNotNull('reg_3', $event)" :searchQuery="reg_3" :modifyCustomerOnChange="false" :automaticallySelect="true"/>
<RegistrationNumberSearchResult @select="setIfNotNull('reg_3', $event)" :searchQuery="reg3Draft" :modifyCustomerOnChange="false" :automaticallySelect="true"/>
</template>
</PosDepartmentStepMobile1RegistrationNumberInputField>
</template>
<template v-if="props.showButtons">
<!-- Back to scan button -->
<button class="button p-5 is-fullwidth mt-3 is-text" data-testid="pos-mobile-manual-input-close" style="text-decoration: none" @click="$emit('close')">
<button class="button p-5 is-fullwidth mt-3 is-text" data-testid="pos-mobile-manual-input-close" style="text-decoration: none" @click="closeManualInput()">
<span class="m-2"><BarcodeScanner/></span>
<span class="custom-label-button">{{ SessionUser.objects.global.language.or_scan_plates}}</span>
</button>
@@ -203,7 +339,7 @@ onMounted(() => {
<!-- Buttons -->
<PosDepartmentStepMobileFixedBottomControl v-if="props.showButtons">
<!-- Close button -->
<PosDepartmentStepMobileButtonNextStep :isWhite="false" :customAction="() => emit('close')" :customDisabled="false" :buttonClasses="['has-background-primary', 'has-text-black']">
<PosDepartmentStepMobileButtonNextStep :isWhite="false" :customAction="() => closeManualInput()" :customDisabled="false" :buttonClasses="['has-background-primary', 'has-text-black']">
<span class="pos-mobile-action-content">
<span class="pos-mobile-action-label">{{ SessionUser.objects.global.language.close }}</span>
<span class="pos-mobile-action-icon">
+147
View File
@@ -0,0 +1,147 @@
import { computed, onBeforeUnmount, ref, watch } from "vue";
export const ORDER_METADATA_AUTOSAVE_DELAY_MS = 600;
const defaultNormalizeValue = (value) => (value == null ? "" : String(value));
const resolveSourceValue = (source) => {
if (typeof source === "function") {
return source();
}
return source?.value;
};
export const useOrderMetadataAutosave = ({
source,
saveValue,
normalizeValue = defaultNormalizeValue,
delay = ORDER_METADATA_AUTOSAVE_DELAY_MS,
onSaved = null,
onError = null,
}) => {
const draft = ref(normalizeValue(resolveSourceValue(source)));
const lastSavedValue = ref(normalizeValue(resolveSourceValue(source)));
const isSaving = ref(false);
const isDirty = computed(() => normalizeValue(draft.value) !== normalizeValue(lastSavedValue.value));
let timerId = null;
let saveAgainAfterCurrentRequest = false;
let nextSequence = 0;
let lastAppliedSequence = 0;
const clearPendingSave = () => {
if (timerId !== null) {
window.clearTimeout(timerId);
timerId = null;
}
};
const syncFromSource = (value) => {
const normalizedValue = normalizeValue(value);
const shouldHydrateDraft = normalizeValue(draft.value) === normalizeValue(lastSavedValue.value);
lastSavedValue.value = normalizedValue;
if (shouldHydrateDraft) {
draft.value = normalizedValue;
}
};
watch(() => resolveSourceValue(source), syncFromSource, { immediate: true });
const persist = async () => {
clearPendingSave();
if (!isDirty.value) {
return false;
}
if (isSaving.value) {
saveAgainAfterCurrentRequest = true;
return false;
}
const requestedValue = normalizeValue(draft.value);
const sequence = ++nextSequence;
isSaving.value = true;
try {
const result = await saveValue(requestedValue);
const savedValue = normalizeValue(result ?? requestedValue);
if (sequence >= lastAppliedSequence) {
lastAppliedSequence = sequence;
lastSavedValue.value = savedValue;
if (normalizeValue(draft.value) === requestedValue) {
draft.value = savedValue;
}
}
if (typeof onSaved === "function") {
await onSaved(savedValue, requestedValue);
}
return true;
} catch (error) {
if (typeof onError === "function") {
onError(error);
} else {
console.error(error);
}
return false;
} finally {
isSaving.value = false;
if (saveAgainAfterCurrentRequest || isDirty.value) {
saveAgainAfterCurrentRequest = false;
if (isDirty.value) {
void persist();
}
}
}
};
const scheduleSave = () => {
clearPendingSave();
if (!isDirty.value) {
return;
}
timerId = window.setTimeout(() => {
void persist();
}, delay);
};
const flush = async () => {
return persist();
};
onBeforeUnmount(() => {
clearPendingSave();
if (isSaving.value) {
saveAgainAfterCurrentRequest = true;
return;
}
if (isDirty.value) {
void persist();
}
});
return {
draft,
lastSavedValue,
isDirty,
isSaving,
scheduleSave,
flush,
clearPendingSave,
syncFromSource,
};
};
+124
View File
@@ -235,6 +235,22 @@ async function waitForOrderItemMutation(page: Page, method: "POST" | "PUT" | "DE
});
}
async function waitForOrderMutation(
page: Page,
method: "PUT" | "POST" | "DELETE",
endpoint: "/order" | "/orders",
predicate: (body: Record<string, unknown>) => boolean
) {
return page.waitForRequest((request) => {
if (request.method() !== method || !request.url().includes(endpoint)) {
return false;
}
const body = (request.postDataJSON?.() || {}) as Record<string, unknown>;
return predicate(body);
});
}
test.describe("Admin POS Orders - desktop settings", () => {
test.beforeEach(async ({ page }, testInfo) => {
test.skip(!testInfo.project.name.includes("desktop"), "Desktop-only order settings coverage");
@@ -518,6 +534,114 @@ test.describe("Admin POS Orders - desktop settings", () => {
await expect(stepTwo.getByTestId("pos-next-step")).toBeVisible();
});
test("autosaves reference and note metadata after a typing pause without blur", async ({ page }) => {
await openOrderDetail(page);
const referenceValue = "AUTOSAVE-REF-2026";
const noteValue = "Autosaved desktop note";
const referenceRequest = waitForOrderMutation(
page,
"PUT",
"/orders",
(body) => Number(body.id) === 54518 && body.reference === referenceValue
);
await page.getByTestId("pos-order-metadata-reference").locator("button").click();
const referenceInput = page.getByTestId("pos-order-reference-textarea");
await referenceInput.fill(referenceValue);
const capturedReferenceRequest = await referenceRequest;
expect(capturedReferenceRequest.postDataJSON()).toMatchObject({
id: 54518,
reference: referenceValue,
});
await expect(referenceInput).toBeFocused();
const noteRequest = waitForOrderMutation(
page,
"PUT",
"/orders",
(body) => Number(body.id) === 54518 && body.notes === noteValue
);
await page.getByTestId("pos-order-metadata-note").locator("button").click();
const noteInput = page.getByTestId("pos-order-note-textarea");
await noteInput.fill(noteValue);
const capturedNoteRequest = await noteRequest;
expect(capturedNoteRequest.postDataJSON()).toMatchObject({
id: 54518,
notes: noteValue,
});
await expect(noteInput).toBeFocused();
await page.reload();
await expect(page.getByTestId("pos-order-detail")).toBeVisible();
await expect(page.getByTestId("pos-order-metadata-reference")).toContainText(referenceValue);
await expect(page.getByTestId("pos-order-metadata-note")).toContainText(noteValue);
});
test("autosaves inline registration metadata and reloads normalized values", async ({ page }) => {
await openOrderDetail(page);
const rawReg1 = "xy-12 34";
const rawReg2 = "tr/56 78";
const rawReg3 = "no_90 12";
const reg1Request = waitForOrderMutation(
page,
"PUT",
"/orders",
(body) => Number(body.id) === 54518 && body.reg_1 === "XY-12 34"
);
await page.getByTestId("pos-order-registration-1").click();
const reg1Input = page.getByTestId("pos-order-registration-input-1");
await reg1Input.fill(rawReg1);
const capturedReg1Request = await reg1Request;
expect(capturedReg1Request.postDataJSON()).toMatchObject({
id: 54518,
reg_1: "XY-12 34",
});
await expect(reg1Input).toHaveValue("XY1234", { timeout: 10000 });
const reg2Request = waitForOrderMutation(
page,
"PUT",
"/orders",
(body) => Number(body.id) === 54518 && body.reg_2 === "TR/56 78"
);
await page.getByTestId("pos-order-registration-add-2").click();
const reg2Input = page.getByTestId("pos-order-registration-input-2");
await reg2Input.fill(rawReg2);
const capturedReg2Request = await reg2Request;
expect(capturedReg2Request.postDataJSON()).toMatchObject({
id: 54518,
reg_2: "TR/56 78",
});
await expect(reg2Input).toHaveValue("TR5678", { timeout: 10000 });
const reg3Request = waitForOrderMutation(
page,
"PUT",
"/orders",
(body) => Number(body.id) === 54518 && body.reg_3 === "NO_90 12"
);
await page.getByTestId("pos-order-registration-add-3").click();
const reg3Input = page.getByTestId("pos-order-registration-input-3");
await reg3Input.fill(rawReg3);
const capturedReg3Request = await reg3Request;
expect(capturedReg3Request.postDataJSON()).toMatchObject({
id: 54518,
reg_3: "NO_90 12",
});
await expect(reg3Input).toHaveValue("NO9012", { timeout: 10000 });
await page.reload();
await expect(page.getByTestId("pos-order-detail")).toBeVisible();
await expect(page.getByTestId("pos-order-registration-1")).toContainText("XY1234");
await expect(page.getByTestId("pos-order-registration-2")).toContainText("TR5678");
await expect(page.getByTestId("pos-order-registration-3")).toContainText("NO9012");
});
test("edits a primary order item in the Buefy modal and persists after reload", async ({ page }) => {
await openOrderDetail(page);
await expectOrderTotal(page, 1372);
+76 -3
View File
@@ -472,7 +472,7 @@ test.describe("POS mobile order flow", () => {
expect(fixture.requestCounters.orderGet).toBeGreaterThan(0);
});
test("step 2 query bootstrap auto-loads the primary product and persists notes/reference", async ({ page }) => {
test("step 2 query bootstrap auto-loads the primary product and persists notes/reference after a typing pause", async ({ page }) => {
const orderId = 9401;
const fixture = createMobilePosFixture({
ordersById: {
@@ -517,15 +517,88 @@ test.describe("POS mobile order flow", () => {
await page.getByTestId("pos-mobile-notes-input").scrollIntoViewIfNeeded();
await expectAboveFixedActions(page, page.getByTestId("pos-mobile-notes-input"));
await page.getByTestId("pos-mobile-notes-input").fill("mobile-pos-notes");
await page.getByTestId("pos-mobile-notes-input").press("Tab");
await page.getByTestId("pos-mobile-reference-step-2-input").scrollIntoViewIfNeeded();
await expectAboveFixedActions(page, page.getByTestId("pos-mobile-reference-step-2-input"));
await page.getByTestId("pos-mobile-reference-step-2-input").fill("mobile-pos-reference");
await page.getByTestId("pos-mobile-reference-step-2-input").press("Tab");
await expect.poll(() => fixture.ordersById[orderId]?.notes ?? "").toBe("mobile-pos-notes");
await expect.poll(() => fixture.ordersById[orderId]?.reference ?? "").toBe("mobile-pos-reference");
await expect
.poll(
() =>
fixture.requestLog.orderUpdates.some(
(entry) => Number(entry?.id) === orderId && entry?.notes === "mobile-pos-notes"
),
{ timeout: 10_000 }
)
.toBe(true);
await expect
.poll(
() =>
fixture.requestLog.orderUpdates.some(
(entry) => Number(entry?.id) === orderId && entry?.reference === "mobile-pos-reference"
),
{ timeout: 10_000 }
)
.toBe(true);
});
test("step 2 registration popup flushes edits on close and survives reload", async ({ page }) => {
const orderId = 9405;
const fixture = createMobilePosFixture({
ordersById: {
[orderId]: buildRegularOrder(orderId, {
reference: "REG-POPUP-REF",
reg_1: "AB12345",
}),
},
orderItemsByOrderId: {
[orderId]: [],
},
});
await setupMobilePosPage(page, fixture, {
token: "mobile-step2-reg-popup-token",
seedState: {
customerId: REGULAR_CUSTOMER_ID,
reg: "AB12345",
reference: "REG-POPUP-REF",
includePrimaryItem: false,
vehicleType: 53,
},
route: {
step: 2,
orderId,
customerId: REGULAR_CUSTOMER_ID,
},
});
await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 });
await page.locator('[data-testid="pos-mobile-step-2"] .custom-button-secondary').first().click();
await expect(page.getByTestId("pos-mobile-popup")).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId("pos-mobile-manual-input")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("pos-mobile-reg-input-1").fill("cd-12 34");
await page.getByTestId("pos-mobile-popup").locator(".card-footer-item").last().click();
await expect(page.getByTestId("pos-mobile-popup")).toBeHidden({ timeout: 10_000 });
await expect.poll(() => fixture.ordersById[orderId]?.reg_1 ?? "", { timeout: 10_000 }).toBe("CD1234");
await expect
.poll(
() =>
fixture.requestLog.orderUpdates.some(
(entry) => Number(entry?.id) === orderId && entry?.reg_1 === "CD-12 34"
),
{ timeout: 10_000 }
)
.toBe(true);
await page.reload();
await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 });
await expect(page.locator('[data-testid="pos-mobile-step-2"] .custom-button-secondary').first()).toContainText(
"CD1234"
);
});
test("manual step 2 selection supports addons and additional items", async ({ page }) => {
+24 -1
View File
@@ -33,6 +33,17 @@ function toPositiveInteger(value) {
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
}
function normalizeRegistrationValue(value) {
if (value === null || value === undefined) {
return "";
}
return String(value)
.trim()
.toUpperCase()
.replace(/[^A-Z0-9]/g, "");
}
function createCustomer(customerNumber, overrides = {}) {
return {
id: customerNumber,
@@ -1406,9 +1417,21 @@ export async function mockMobilePosApi(page, fixture) {
const order = fixture.ordersById[orderId];
if (order) {
if (body.field) {
order[body.field] = body.value;
order[body.field] =
body.field === "reg_1" || body.field === "reg_2" || body.field === "reg_3"
? normalizeRegistrationValue(body.value)
: body.value;
} else {
Object.assign(order, body);
if (Object.prototype.hasOwnProperty.call(body, "reg_1")) {
order.reg_1 = normalizeRegistrationValue(body.reg_1);
}
if (Object.prototype.hasOwnProperty.call(body, "reg_2")) {
order.reg_2 = normalizeRegistrationValue(body.reg_2);
}
if (Object.prototype.hasOwnProperty.call(body, "reg_3")) {
order.reg_3 = normalizeRegistrationValue(body.reg_3);
}
}
}
await route.fulfill(json({ success: true, data: clone(order || null) }));