Compare commits

...
Author SHA1 Message Date
Worktree Fix Verifier 7058e1a73e test(pleno-vue): queue two API responses for second ordering test too 2026-08-10 20:23:30 +02:00
Worktree Fix Verifier 02e56a3e5f test(pleno-vue): queue two API responses for OrderContentTable mount
OrderContentTable fires loadOrderItems twice on initial mount: once via
the immediate watch on props.orderId and once via the explicit
top-level call. Queue both responses to keep the test deterministic.
2026-08-10 20:22:57 +02:00
Worktree Fix Verifier 97700841ba test(pleno-vue): use vi.hoisted() for the request mock holder
vitest hoists vi.mock factory calls above the module's top-level
bindings, so referencing a plain const from inside the factory throws
"Cannot access X before initialization". Use vi.hoisted() to declare a
mutable holder the mock factory can mutate.
2026-08-10 20:17:12 +02:00
Jeppe B 88d5252814 style(pleno-vue): satisfy Prettier check on the OrderContentTable change
format-tests failed on the previous commits because the new test file and
the .vue file had inconsistent whitespace and missing semicolons. Run
Prettier to fix.
2026-08-10 20:12:44 +02:00
Jeppe B 7d3ebf8d52 test(pleno-vue): cover OrderContentTable addon ordering
Pins the defensive sort in OrderContentTable so a regression cannot
silently put addons before their primary on the invoice table. Covers
the API path, the local-items path, and addon siblings ordering.
2026-08-10 20:06:10 +02:00
Jeppe B 0a03cd5f98 fix(pleno-vue): sort order_items defensively in OrderContentTable
The OrderContentTable component rendered items via v-for on the array
returned by GET /order/items. The backend used to return rows in undefined
order, which put addons before their primary on the invoice and POS
displays.

The api now sorts the SELECT (copenhagentruckwash/api PR), but we sort
defensively here so any older cached or proxied payload still renders
primary first, then addons grouped by their parent, in insertion order.
2026-08-10 20:05:39 +02:00
2 changed files with 468 additions and 173 deletions
@@ -1,8 +1,4 @@
<script setup> <script setup>
import { computed, ref, watch } from "vue"; import { computed, ref, watch } from "vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue"; import { SessionUser } from "@/components/session/token/SessionUser.vue";
import InvoicingPeriodFlagList from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/components/InvoicingPeriodFlagList.vue"; import InvoicingPeriodFlagList from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/components/InvoicingPeriodFlagList.vue";
@@ -40,75 +36,97 @@ const props = defineProps({
type: Array, type: Array,
default: () => [], default: () => [],
}, },
}) });
const emit = defineEmits(["flagStatusChanged"]); const emit = defineEmits(["flagStatusChanged"]);
const orderItems = ref([]); const orderItems = ref([]);
// Stable ordering for the order items table: primary items (related_item_id === 0
// / null) first, then addons grouped by their parent, in insertion order. The
// backend now also orders the SELECT, but we sort defensively here so a stale
// cache or older API proxy cannot regress the render order.
const sortOrderItemsForDisplay = (items) => {
if (!Array.isArray(items)) {
return [];
}
return items.slice().sort((left, right) => {
const leftRelatedId = Number(left?.related_item_id ?? 0);
const rightRelatedId = Number(right?.related_item_id ?? 0);
// Primary items (related_item_id 0 / null) come first.
if ((leftRelatedId === 0) !== (rightRelatedId === 0)) {
return leftRelatedId === 0 ? -1 : 1;
}
// Within addons, group by parent.
if (leftRelatedId !== rightRelatedId) {
return leftRelatedId - rightRelatedId;
}
// Fall back to insertion order.
return Number(left?.id ?? 0) - Number(right?.id ?? 0);
});
};
const loadOrderItems = async () => { const loadOrderItems = async () => {
if (props.useLocalOrderItems) { if (props.useLocalOrderItems) {
orderItems.value = props.localOrderItems; orderItems.value = sortOrderItemsForDisplay(props.localOrderItems);
return; return;
} }
await SessionUser.request( await SessionUser.request("/order/items", "GET", {
'/order/items', order_id: props.orderId,
'GET', })
{ .then((response) => {
order_id: props.orderId, orderItems.value = sortOrderItemsForDisplay(response.data.data);
} })
).then((response) => { .catch((error) => {
orderItems.value = response.data.data; console.error(error);
}).catch((error) => { });
console.error(error); };
});
}
const isOrderItemAddon = (item) => { const isOrderItemAddon = (item) => {
return item.related_item_id !== 0; return item.related_item_id !== 0;
} };
const getItemAddons = (item) => { const getItemAddons = (item) => {
return orderItems.value.filter((order_item) => { return orderItems.value.filter((order_item) => {
return order_item.related_item_id === item.id; return order_item.related_item_id === item.id;
}); });
} };
const _ucFirst = (str) => { const _ucFirst = (str) => {
return str.charAt(0).toUpperCase() + str.slice(1); return str.charAt(0).toUpperCase() + str.slice(1);
} };
const sortInvoicePeriodFlags = (flags = []) => [...flags].sort((left, right) => { const sortInvoicePeriodFlags = (flags = []) =>
const sourceOrder = { manual: 0, automatic: 1 }; [...flags].sort((left, right) => {
const leftSource = sourceOrder[left?.source] ?? 99; const sourceOrder = { manual: 0, automatic: 1 };
const rightSource = sourceOrder[right?.source] ?? 99; const leftSource = sourceOrder[left?.source] ?? 99;
if (leftSource !== rightSource) { const rightSource = sourceOrder[right?.source] ?? 99;
return leftSource - rightSource; if (leftSource !== rightSource) {
} return leftSource - rightSource;
}
return String(left?.created_at || left?.fingerprint || "").localeCompare( return String(left?.created_at || left?.fingerprint || "").localeCompare(
String(right?.created_at || right?.fingerprint || "") String(right?.created_at || right?.fingerprint || "")
);
});
const getOrderItemFlags = (orderItem) =>
sortInvoicePeriodFlags(
(props.invoicePeriodFlags || []).filter((flag) => {
if (String(flag?.status || "active") !== "active") {
return false;
}
const targetType = String(flag?.target_type || "");
if (!["order_item", "order_item_field"].includes(targetType)) {
return false;
}
const flagOrderItemId = Number(flag?.order_item_id || flag?.target_id || flag?.context?.order_item_id || 0);
return flagOrderItemId > 0 && flagOrderItemId === Number(orderItem?.id || 0);
})
); );
});
const getOrderItemFlags = (orderItem) => sortInvoicePeriodFlags((props.invoicePeriodFlags || []).filter((flag) => { const getOrderItemFlagIconClass = (orderItem) => (getOrderItemFlags(orderItem).length > 0 ? "fas fa-flag" : "");
if (String(flag?.status || "active") !== "active") {
return false;
}
const targetType = String(flag?.target_type || "");
if (!["order_item", "order_item_field"].includes(targetType)) {
return false;
}
const flagOrderItemId = Number(flag?.order_item_id || flag?.target_id || flag?.context?.order_item_id || 0);
return flagOrderItemId > 0 && flagOrderItemId === Number(orderItem?.id || 0);
}));
const getOrderItemFlagIconClass = (orderItem) => (
getOrderItemFlags(orderItem).length > 0 ? "fas fa-flag" : ""
);
const getOrderItemFlagColorClass = (orderItem) => { const getOrderItemFlagColorClass = (orderItem) => {
const flags = getOrderItemFlags(orderItem); const flags = getOrderItemFlags(orderItem);
@@ -119,19 +137,23 @@ const getOrderItemFlagColorClass = (orderItem) => {
return flags.some((flag) => flag?.source === "manual") ? "has-text-danger" : "has-text-warning"; return flags.some((flag) => flag?.source === "manual") ? "has-text-danger" : "has-text-warning";
}; };
const displayColumnCount = computed(() => ( const displayColumnCount = computed(
2 + (props.displayNotes ? 1 : 0) + (props.displayReference ? 1 : 0) + (props.displayPrice ? 1 : 0) () => 2 + (props.displayNotes ? 1 : 0) + (props.displayReference ? 1 : 0) + (props.displayPrice ? 1 : 0)
)); );
const emitFlagStatusChanged = (flag) => emit("flagStatusChanged", flag); const emitFlagStatusChanged = (flag) => emit("flagStatusChanged", flag);
loadOrderItems(); loadOrderItems();
watch(() => props.orderId, (newValue, oldValue) => { watch(
if (newValue !== oldValue) { () => props.orderId,
loadOrderItems(); (newValue, oldValue) => {
} if (newValue !== oldValue) {
}, { immediate: true }); loadOrderItems();
}
},
{ immediate: true }
);
</script> </script>
<template> <template>
@@ -139,125 +161,128 @@ watch(() => props.orderId, (newValue, oldValue) => {
<table class="table is-fullwidth" v-if="!props.summaryMode"> <table class="table is-fullwidth" v-if="!props.summaryMode">
<thead> <thead>
<tr> <tr>
<th>{{ $t('tables.products.name') }}</th> <th>{{ $t("tables.products.name") }}</th>
<th v-if="props.displayNotes">{{ $t('objects.columns.notes') }}</th> <th v-if="props.displayNotes">{{ $t("objects.columns.notes") }}</th>
<th v-if="props.displayReference">{{ $t('common.reference') }}</th> <th v-if="props.displayReference">{{ $t("common.reference") }}</th>
<th>{{ $t('common.quantity') }}</th> <th>{{ $t("common.quantity") }}</th>
<th v-if="props.displayPrice">{{ $t('tables.products.price') }}</th> <th v-if="props.displayPrice">{{ $t("tables.products.price") }}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<template v-for="item in orderItems" :key="item.id"> <template v-for="item in orderItems" :key="item.id">
<!-- If the item is a primary item, show it --> <!-- If the item is a primary item, show it -->
<template v-if="!isOrderItemAddon(item)"> <template v-if="!isOrderItemAddon(item)">
<!-- If the item is included in the invoice, show it normally --> <!-- If the item is included in the invoice, show it normally -->
<tr v-if="item.include_in_invoice"> <tr v-if="item.include_in_invoice">
<td> <td>
<span <span
v-if="getOrderItemFlags(item).length > 0" v-if="getOrderItemFlags(item).length > 0"
class="icon is-small mr-1 invoice-period-item-flag-indicator" class="icon is-small mr-1 invoice-period-item-flag-indicator"
:class="getOrderItemFlagColorClass(item)" :class="getOrderItemFlagColorClass(item)"
:data-testid="`order-content-item-flag-indicator-${item.id}`" :data-testid="`order-content-item-flag-indicator-${item.id}`"
> >
<i :class="getOrderItemFlagIconClass(item)"></i> <i :class="getOrderItemFlagIconClass(item)"></i>
</span> </span>
{{ item.product.name }} {{ item.product.name }}
</td> </td>
<td v-if="props.displayReference">{{ item.reference }}</td> <td v-if="props.displayReference">{{ item.reference }}</td>
<td v-if="props.displayNotes">{{ item.notes }}</td> <td v-if="props.displayNotes">{{ item.notes }}</td>
<td>{{ item.quantity }}</td> <td>{{ item.quantity }}</td>
<td v-if="props.displayPrice">{{ SessionUser.functions.currency.toLocal(item.price) }}</td> <td v-if="props.displayPrice">{{ SessionUser.functions.currency.toLocal(item.price) }}</td>
</tr> </tr>
<!-- If the item is not included in the invoice, show it with a strikethrough --> <!-- If the item is not included in the invoice, show it with a strikethrough -->
<tr v-else class="has-background-warning-light"> <tr v-else class="has-background-warning-light">
<td> <td>
<span <span
v-if="getOrderItemFlags(item).length > 0" v-if="getOrderItemFlags(item).length > 0"
class="icon is-small mr-1 invoice-period-item-flag-indicator" class="icon is-small mr-1 invoice-period-item-flag-indicator"
:class="getOrderItemFlagColorClass(item)" :class="getOrderItemFlagColorClass(item)"
:data-testid="`order-content-item-flag-indicator-${item.id}`" :data-testid="`order-content-item-flag-indicator-${item.id}`"
> >
<i :class="getOrderItemFlagIconClass(item)"></i> <i :class="getOrderItemFlagIconClass(item)"></i>
</span> </span>
<span style="text-decoration: line-through;">{{ item.product.name }}</span> ( {{SessionUser.objects.vehicles.columns.wash_subscription.label }} ) <span style="text-decoration: line-through">{{ item.product.name }}</span> (
</td> {{ SessionUser.objects.vehicles.columns.wash_subscription.label }} )
<td v-if="props.displayReference">{{ item.reference }}</td> </td>
<td v-if="props.displayNotes">{{ item.notes }}</td> <td v-if="props.displayReference">{{ item.reference }}</td>
<td>{{ item.quantity }}</td> <td v-if="props.displayNotes">{{ item.notes }}</td>
<td v-if="props.displayPrice" style="text-decoration: line-through;">{{ SessionUser.functions.currency.toLocal(item.price) }}</td> <td>{{ item.quantity }}</td>
</tr> <td v-if="props.displayPrice" style="text-decoration: line-through">
<tr {{ SessionUser.functions.currency.toLocal(item.price) }}
v-if="getOrderItemFlags(item).length > 0" </td>
:data-testid="`order-content-item-flags-${item.id}`" </tr>
> <tr v-if="getOrderItemFlags(item).length > 0" :data-testid="`order-content-item-flags-${item.id}`">
<td :colspan="displayColumnCount"> <td :colspan="displayColumnCount">
<InvoicingPeriodFlagList <InvoicingPeriodFlagList
compact compact
:flags="getOrderItemFlags(item)" :flags="getOrderItemFlags(item)"
@statusChanged="emitFlagStatusChanged" @statusChanged="emitFlagStatusChanged"
/> />
</td> </td>
</tr> </tr>
</template>
<!-- Show the add-on items -->
<template v-for="addon_item in getItemAddons(item)" :key="addon_item.id">
<!-- If the add-on item is included in the invoice, show it normally -->
<tr v-if="addon_item.include_in_invoice">
<td>
<span
v-if="getOrderItemFlags(addon_item).length > 0"
class="icon is-small mr-1 invoice-period-item-flag-indicator"
:class="getOrderItemFlagColorClass(addon_item)"
:data-testid="`order-content-item-flag-indicator-${addon_item.id}`"
>
<i :class="getOrderItemFlagIconClass(addon_item)"></i>
</span>
+ {{ addon_item.product.name }}
</td>
<td v-if="props.displayReference">{{ addon_item.reference }}</td>
<td v-if="props.displayNotes">{{ addon_item.notes }}</td>
<td>{{ addon_item.quantity }}</td>
<td v-if="props.displayPrice">{{ SessionUser.functions.currency.toLocal(addon_item.price) }}</td>
</tr>
<!-- If the add-on item is not included in the invoice, show it with a strikethrough -->
<tr v-else class="has-background-warning">
<td>
<span
v-if="getOrderItemFlags(addon_item).length > 0"
class="icon is-small mr-1 invoice-period-item-flag-indicator"
:class="getOrderItemFlagColorClass(addon_item)"
:data-testid="`order-content-item-flag-indicator-${addon_item.id}`"
>
<i :class="getOrderItemFlagIconClass(addon_item)"></i>
</span>
+ <span style="text-decoration: line-through">{{ addon_item.product.name }}</span> (
{{ SessionUser.objects.vehicles.columns.wash_subscription.label }} )
</td>
<td v-if="props.displayReference">{{ addon_item.reference }}</td>
<td v-if="props.displayNotes">{{ addon_item.notes }}</td>
<td>{{ addon_item.quantity }}</td>
<td v-if="props.displayPrice" style="text-decoration: line-through">
{{ SessionUser.functions.currency.toLocal(addon_item.price) }}
</td>
</tr>
<tr
v-if="getOrderItemFlags(addon_item).length > 0"
:data-testid="`order-content-item-flags-${addon_item.id}`"
>
<td :colspan="displayColumnCount">
<InvoicingPeriodFlagList
compact
:flags="getOrderItemFlags(addon_item)"
@statusChanged="emitFlagStatusChanged"
/>
</td>
</tr>
</template>
</template> </template>
<!-- Show the add-on items -->
<template v-for="addon_item in getItemAddons(item)" :key="addon_item.id">
<!-- If the add-on item is included in the invoice, show it normally -->
<tr v-if="addon_item.include_in_invoice">
<td>
<span
v-if="getOrderItemFlags(addon_item).length > 0"
class="icon is-small mr-1 invoice-period-item-flag-indicator"
:class="getOrderItemFlagColorClass(addon_item)"
:data-testid="`order-content-item-flag-indicator-${addon_item.id}`"
>
<i :class="getOrderItemFlagIconClass(addon_item)"></i>
</span>
+ {{ addon_item.product.name }}
</td>
<td v-if="props.displayReference">{{ addon_item.reference }}</td>
<td v-if="props.displayNotes">{{ addon_item.notes }}</td>
<td>{{ addon_item.quantity }}</td>
<td v-if="props.displayPrice">{{ SessionUser.functions.currency.toLocal(addon_item.price) }}</td>
</tr>
<!-- If the add-on item is not included in the invoice, show it with a strikethrough -->
<tr v-else class="has-background-warning">
<td>
<span
v-if="getOrderItemFlags(addon_item).length > 0"
class="icon is-small mr-1 invoice-period-item-flag-indicator"
:class="getOrderItemFlagColorClass(addon_item)"
:data-testid="`order-content-item-flag-indicator-${addon_item.id}`"
>
<i :class="getOrderItemFlagIconClass(addon_item)"></i>
</span>
+ <span style="text-decoration: line-through;">{{ addon_item.product.name }}</span> ( {{SessionUser.objects.vehicles.columns.wash_subscription.label }} )
</td>
<td v-if="props.displayReference">{{ addon_item.reference }}</td>
<td v-if="props.displayNotes">{{ addon_item.notes }}</td>
<td>{{ addon_item.quantity }}</td>
<td v-if="props.displayPrice" style="text-decoration: line-through;">{{ SessionUser.functions.currency.toLocal(addon_item.price) }}</td>
</tr>
<tr
v-if="getOrderItemFlags(addon_item).length > 0"
:data-testid="`order-content-item-flags-${addon_item.id}`"
>
<td :colspan="displayColumnCount">
<InvoicingPeriodFlagList
compact
:flags="getOrderItemFlags(addon_item)"
@statusChanged="emitFlagStatusChanged"
/>
</td>
</tr>
</template>
</template>
</tbody> </tbody>
</table> </table>
<!-- Summary Mode --> <!-- Summary Mode -->
<div v-else> <div v-else>
<!-- Loader --> <!-- Loader -->
<template v-if="orderItems.length === 0"> <template v-if="orderItems.length === 0">
<span class="is-flex is-justify-content-center is-align-items-center" style="height: 100px;"> <span class="is-flex is-justify-content-center is-align-items-center" style="height: 100px">
<span class="loader is-loading is-align-self-center"></span> <span class="loader is-loading is-align-self-center"></span>
</span> </span>
</template> </template>
@@ -278,11 +303,7 @@ watch(() => props.orderId, (newValue, oldValue) => {
<strong>{{ item.product.name }}</strong> x {{ item.quantity }} <strong>{{ item.product.name }}</strong> x {{ item.quantity }}
<span v-if="props.displayPrice"> - {{ SessionUser.functions.currency.toLocal(item.price) }}</span> <span v-if="props.displayPrice"> - {{ SessionUser.functions.currency.toLocal(item.price) }}</span>
</div> </div>
<InvoicingPeriodFlagList <InvoicingPeriodFlagList compact :flags="getOrderItemFlags(item)" @statusChanged="emitFlagStatusChanged" />
compact
:flags="getOrderItemFlags(item)"
@statusChanged="emitFlagStatusChanged"
/>
<!-- Show the add-on items --> <!-- Show the add-on items -->
<div v-for="addon_item in getItemAddons(item)" :key="addon_item.id" class="ml-4"> <div v-for="addon_item in getItemAddons(item)" :key="addon_item.id" class="ml-4">
<small> <small>
@@ -294,7 +315,7 @@ watch(() => props.orderId, (newValue, oldValue) => {
> >
<i :class="getOrderItemFlagIconClass(addon_item)"></i> <i :class="getOrderItemFlagIconClass(addon_item)"></i>
</span> </span>
+ {{ addon_item.product.name }} x {{ addon_item.quantity }} + {{ addon_item.product.name }} x {{ addon_item.quantity }}
<span v-if="props.displayPrice"> - {{ SessionUser.functions.currency.toLocal(addon_item.price) }}</span> <span v-if="props.displayPrice"> - {{ SessionUser.functions.currency.toLocal(addon_item.price) }}</span>
</small> </small>
<InvoicingPeriodFlagList <InvoicingPeriodFlagList
@@ -0,0 +1,274 @@
// @vitest-environment jsdom
import { flushPromises, mount } from "@vue/test-utils";
import { beforeEach, describe, expect, it, vi } from "vitest";
import OrderContentTable from "@/components/displays/superuser/tables/OrderContentTable.vue";
import { createTestI18n } from "./helpers/mountWithApp.js";
// Track mock state in a hoisted holder so the vi.mock factory below can stay
// free of top-level variables (vitest hoists vi.mock above imports, so the
// factory must not reference any binding that has not been hoisted).
const requestHolder = vi.hoisted(() => ({
responses: [],
calls: 0,
}));
const showPopperMock = vi.hoisted(() => vi.fn());
const removePopperIfOpenMock = vi.hoisted(() => vi.fn());
const popperBoxMock = vi.hoisted(() => vi.fn((title, body) => ({ title, body })));
const swalFireMock = vi.hoisted(() => vi.fn(() => Promise.resolve({ isConfirmed: false })));
vi.mock("sweetalert2", () => ({
default: {
fire: swalFireMock,
},
}));
vi.mock("@/components/displays/PopperDefault.vue", () => ({
showPopper: showPopperMock,
removePopperIfOpen: removePopperIfOpenMock,
popperBox: popperBoxMock,
}));
vi.mock("@/components/session/token/SessionUser.vue", () => ({
SessionUser: {
request: () => {
requestHolder.calls += 1;
const next = requestHolder.responses.shift();
return Promise.resolve(next ?? { data: { data: [] } });
},
functions: {
currency: {
toLocal: (value) => String(value ?? ""),
},
redirectTo: {
department: vi.fn(),
},
},
objects: {
vehicles: {
columns: {
wash_subscription: {
label: "Wash subscription",
},
},
},
},
},
}));
const i18n = createTestI18n({
en: {
global: {
cancel: "Cancel",
no_data: "No data",
quantity: "Quantity",
},
tables: {
products: {
name: "Product",
price: "Price",
},
},
objects: {
columns: {
notes: "Notes",
reference: "Reference",
},
},
invoice_period: {
flags: {
status: {
resolved: "Resolved",
ignored: "Ignored",
false_positive: "False positive",
reason_placeholder: "Optional reason",
},
},
},
},
});
const buildWrapper = (props = {}) =>
mount(OrderContentTable, {
props: {
orderId: 42,
...props,
},
global: {
plugins: [i18n],
},
});
describe("OrderContentTable addon ordering", () => {
beforeEach(() => {
requestHolder.responses = [];
requestHolder.calls = 0;
showPopperMock.mockReset();
removePopperIfOpenMock.mockReset();
popperBoxMock.mockReset();
swalFireMock.mockReset();
});
it("renders primary items before their addons even when the API returns them out of order", async () => {
// Simulate the legacy MySQL response: addons appear before the primary
// item they belong to. After the fix, the component must render the
// primary first.
const apiResponse = [
{
id: 102,
order_id: 42,
product_id: 9,
related_item_id: 101,
price: 50,
quantity: 1,
include_in_invoice: true,
notes: "",
reference: "",
product: { id: 9, name: "Addon A" },
},
{
id: 103,
order_id: 42,
product_id: 10,
related_item_id: 101,
price: 30,
quantity: 1,
include_in_invoice: true,
notes: "",
reference: "",
product: { id: 10, name: "Addon B" },
},
{
id: 101,
order_id: 42,
product_id: 1,
related_item_id: 0,
price: 200,
quantity: 1,
include_in_invoice: true,
notes: "",
reference: "",
product: { id: 1, name: "Primary wash" },
},
];
requestHolder.responses.push({ data: { data: apiResponse } });
requestHolder.responses.push({ data: { data: apiResponse } });
const wrapper = buildWrapper();
await flushPromises();
const renderedRows = wrapper.findAll("tbody tr");
const productNames = renderedRows.map((row) => String(row.text() || "").trim());
const primaryIndex = productNames.findIndex((text) => text.includes("Primary wash"));
const addonAIndex = productNames.findIndex((text) => text.includes("Addon A"));
const addonBIndex = productNames.findIndex((text) => text.includes("Addon B"));
expect(primaryIndex).toBeGreaterThanOrEqual(0);
expect(addonAIndex).toBeGreaterThanOrEqual(0);
expect(addonBIndex).toBeGreaterThanOrEqual(0);
expect(primaryIndex).toBeLessThan(addonAIndex);
expect(primaryIndex).toBeLessThan(addonBIndex);
});
it("preserves insertion order for addons that share the same parent", async () => {
const apiResponse = [
{
id: 101,
order_id: 42,
product_id: 1,
related_item_id: 0,
price: 200,
quantity: 1,
include_in_invoice: true,
notes: "",
reference: "",
product: { id: 1, name: "Primary wash" },
},
{
id: 103,
order_id: 42,
product_id: 10,
related_item_id: 101,
price: 30,
quantity: 1,
include_in_invoice: true,
notes: "",
reference: "",
product: { id: 10, name: "Addon B" },
},
{
id: 102,
order_id: 42,
product_id: 9,
related_item_id: 101,
price: 50,
quantity: 1,
include_in_invoice: true,
notes: "",
reference: "",
product: { id: 9, name: "Addon A" },
},
];
// OrderContentTable mounts twice on initial render (immediate watcher plus
// a top-level call); queue two identical responses.
requestHolder.responses.push({ data: { data: apiResponse } });
requestHolder.responses.push({ data: { data: apiResponse } });
const wrapper = buildWrapper();
await flushPromises();
const renderedRows = wrapper.findAll("tbody tr");
const productNames = renderedRows.map((row) => String(row.text() || "").trim());
const addonAIndex = productNames.findIndex((text) => text.includes("Addon A"));
const addonBIndex = productNames.findIndex((text) => text.includes("Addon B"));
expect(addonAIndex).toBeLessThan(addonBIndex);
});
it("sorts localOrderItems defensively when useLocalOrderItems is true", async () => {
const localItems = [
{
id: 201,
order_id: 7,
product_id: 9,
related_item_id: 200,
price: 50,
quantity: 1,
include_in_invoice: true,
notes: "",
reference: "",
product: { id: 9, name: "Local addon" },
},
{
id: 200,
order_id: 7,
product_id: 1,
related_item_id: 0,
price: 200,
quantity: 1,
include_in_invoice: true,
notes: "",
reference: "",
product: { id: 1, name: "Local primary" },
},
];
const wrapper = buildWrapper({
orderId: 7,
useLocalOrderItems: true,
localOrderItems: localItems,
});
await flushPromises();
const renderedRows = wrapper.findAll("tbody tr");
const productNames = renderedRows.map((row) => String(row.text() || "").trim());
const primaryIndex = productNames.findIndex((text) => text.includes("Local primary"));
const addonIndex = productNames.findIndex((text) => text.includes("Local addon"));
expect(primaryIndex).toBeLessThan(addonIndex);
expect(requestHolder.calls).toBe(0);
});
});