diff --git a/src/components/displays/superuser/tables/OrderContentTable.vue b/src/components/displays/superuser/tables/OrderContentTable.vue index 115c9e13..347e0b3b 100644 --- a/src/components/displays/superuser/tables/OrderContentTable.vue +++ b/src/components/displays/superuser/tables/OrderContentTable.vue @@ -47,9 +47,35 @@ const emit = defineEmits(["flagStatusChanged"]); 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 (api PR), but we sort defensively here so a +// stale cache or older API proxy cannot regress the render order (which previously +// made it look like only Trailer/Dolly were attached to a Trækker order because +// Spot-free and Undervognsskyld were listed above the primary and visually buried). +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 () => { if (props.useLocalOrderItems) { - orderItems.value = props.localOrderItems; + orderItems.value = sortOrderItemsForDisplay(props.localOrderItems); return; } await SessionUser.request( @@ -59,7 +85,7 @@ const loadOrderItems = async () => { order_id: props.orderId, } ).then((response) => { - orderItems.value = response.data.data; + orderItems.value = sortOrderItemsForDisplay(response.data.data); }).catch((error) => { console.error(error); }); diff --git a/tests/unit/order-content-table-addon-ordering.spec.js b/tests/unit/order-content-table-addon-ordering.spec.js new file mode 100644 index 00000000..f8a475e7 --- /dev/null +++ b/tests/unit/order-content-table-addon-ordering.spec.js @@ -0,0 +1,318 @@ +// @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 { SessionUser } from "@/components/session/token/SessionUser.vue"; +import { createTestI18n } from "./helpers/mountWithApp.js"; + +const requestMock = vi.hoisted(() => vi.fn()); +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: requestMock, + 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", + reference: "Reference", + }, + tables: { + products: { + name: "Product", + price: "Price", + }, + }, + objects: { + columns: { + notes: "Notes", + }, + }, + invoice_period: { + flags: { + status: { + resolved: "Resolved", + ignored: "Ignored", + false_positive: "False positive", + reason_placeholder: "Optional reason", + }, + }, + }, + }, +}); + +const buildItem = (overrides) => ({ + id: overrides.id, + related_item_id: overrides.related_item_id ?? 0, + product_id: overrides.product_id ?? overrides.id, + product: { + id: overrides.product_id ?? overrides.id, + name: overrides.name, + }, + quantity: 1, + price: 0, + notes: "", + reference: "", + include_in_invoice: true, +}); + +// Read the rendered product rows in display order. The component renders a +// primary item as plain text in the first cell, and an addon as "+ ". +// We ignore icon flag nodes and collapse whitespace. The returned shape is +// { name, isAddon } so callers can assert on primary vs addon rows. +const collectRenderedRows = (wrapper) => { + const cells = wrapper.element.querySelectorAll("tbody td:first-child"); + const rows = []; + cells.forEach((td) => { + const children = Array.from(td.children).filter( + (node) => !node.classList?.contains("invoice-period-item-flag-indicator") + ); + const text = (children.length ? children : [td]) + .map((node) => node.textContent || "") + .join(" ") + .trim(); + if (!text) { + return; + } + const isAddon = text.startsWith("+"); + const name = isAddon ? text.slice(1).trim() : text; + rows.push({ name, isAddon, raw: text }); + }); + return rows; +}; + +// Helper that returns the index of the first row matching the predicate. +const indexOfRow = (rows, predicate) => rows.findIndex(predicate); + +describe("OrderContentTable addon ordering", () => { + beforeEach(() => { + requestMock.mockReset(); + swalFireMock.mockClear(); + showPopperMock.mockClear(); + removePopperIfOpenMock.mockClear(); + popperBoxMock.mockClear(); + }); + + it("places the primary before its addons when the API returns them out of order", async () => { + const primary = buildItem({ id: 101, product_id: 11, name: "Traekker" }); + const trailerAddon = buildItem({ id: 102, product_id: 12, name: "TrailerAddon", related_item_id: 101 }); + const dollyAddon = buildItem({ id: 103, product_id: 13, name: "DollyAddon", related_item_id: 101 }); + const spotFreeAddon = buildItem({ id: 104, product_id: 14, name: "SpotFreeAddon", related_item_id: 101 }); + const undervognAddon = buildItem({ id: 105, product_id: 15, name: "UndervognAddon", related_item_id: 101 }); + + // API returns rows in undefined order (addons listed before their primary). + const apiOrderItems = [trailerAddon, spotFreeAddon, dollyAddon, primary, undervognAddon]; + + requestMock.mockResolvedValue({ data: { data: apiOrderItems } }); + + const wrapper = mount(OrderContentTable, { + props: { orderId: 9001 }, + global: { + plugins: [i18n], + stubs: { + InvoicingPeriodFlagList: true, + }, + }, + }); + + await flushPromises(); + await flushPromises(); + + const rows = collectRenderedRows(wrapper); + + // The primary ("Traekker") must always appear before its addons, regardless of API order. + const primaryIndex = indexOfRow(rows, (row) => row.name === "Traekker"); + expect(primaryIndex).toBeGreaterThanOrEqual(0); + + ["TrailerAddon", "DollyAddon", "SpotFreeAddon", "UndervognAddon"].forEach((name) => { + const idx = indexOfRow(rows, (row) => row.name === name && row.isAddon); + expect(idx).toBeGreaterThanOrEqual(0); + expect(idx).toBeGreaterThan(primaryIndex); + }); + + wrapper.unmount(); + }); + + it("groups sibling addons under their parent when several primaries are present", async () => { + const primaryA = buildItem({ id: 201, product_id: 21, name: "PrimaryA" }); + const primaryB = buildItem({ id: 202, product_id: 22, name: "PrimaryB" }); + const addonA1 = buildItem({ id: 203, product_id: 31, name: "AddonA1", related_item_id: 201 }); + const addonA2 = buildItem({ id: 204, product_id: 32, name: "AddonA2", related_item_id: 201 }); + const addonB1 = buildItem({ id: 205, product_id: 33, name: "AddonB1", related_item_id: 202 }); + + const apiOrderItems = [addonA1, addonB1, primaryB, addonA2, primaryA]; + + requestMock.mockResolvedValue({ data: { data: apiOrderItems } }); + + const wrapper = mount(OrderContentTable, { + props: { orderId: 9002 }, + global: { + plugins: [i18n], + stubs: { + InvoicingPeriodFlagList: true, + }, + }, + }); + + await flushPromises(); + await flushPromises(); + + const rows = collectRenderedRows(wrapper); + + const primaryAIndex = indexOfRow(rows, (row) => row.name === "PrimaryA"); + const primaryBIndex = indexOfRow(rows, (row) => row.name === "PrimaryB"); + const addonA1Index = indexOfRow(rows, (row) => row.name === "AddonA1"); + const addonA2Index = indexOfRow(rows, (row) => row.name === "AddonA2"); + const addonB1Index = indexOfRow(rows, (row) => row.name === "AddonB1"); + + expect(primaryAIndex).toBeGreaterThanOrEqual(0); + expect(primaryBIndex).toBeGreaterThanOrEqual(0); + expect(primaryAIndex).toBeLessThan(primaryBIndex); + expect(addonA1Index).toBeGreaterThan(primaryAIndex); + expect(addonA2Index).toBeGreaterThan(primaryAIndex); + expect(addonA1Index).toBeLessThan(primaryBIndex); + expect(addonA2Index).toBeLessThan(primaryBIndex); + expect(addonB1Index).toBeGreaterThan(primaryBIndex); + + wrapper.unmount(); + }); + + it("preserves a stable order when the consumer passes local items directly", async () => { + const primary = buildItem({ id: 301, product_id: 31, name: "TraekkerLocal" }); + const addon = buildItem({ id: 302, product_id: 32, name: "TrailerAddonLocal", related_item_id: 301 }); + + requestMock.mockResolvedValue({ data: { data: [] } }); + + const wrapper = mount(OrderContentTable, { + props: { + orderId: 9003, + useLocalOrderItems: true, + localOrderItems: [addon, primary], + }, + global: { + plugins: [i18n], + stubs: { + InvoicingPeriodFlagList: true, + }, + }, + }); + + await flushPromises(); + await flushPromises(); + + const rows = collectRenderedRows(wrapper); + const primaryIndex = indexOfRow(rows, (row) => row.name === "TraekkerLocal"); + const addonIndex = indexOfRow(rows, (row) => row.name === "TrailerAddonLocal"); + expect(primaryIndex).toBeGreaterThanOrEqual(0); + expect(addonIndex).toBeGreaterThanOrEqual(0); + expect(primaryIndex).toBeLessThan(addonIndex); + + wrapper.unmount(); + }); + + it("does not call the API when local order items are provided", async () => { + const primary = buildItem({ id: 401, product_id: 41, name: "TraekkerNoApi" }); + + requestMock.mockResolvedValue({ data: { data: [primary] } }); + + const wrapper = mount(OrderContentTable, { + props: { + orderId: 9004, + useLocalOrderItems: true, + localOrderItems: [primary], + }, + global: { + plugins: [i18n], + stubs: { + InvoicingPeriodFlagList: true, + }, + }, + }); + + await flushPromises(); + await flushPromises(); + + expect(requestMock).not.toHaveBeenCalled(); + wrapper.unmount(); + }); + + it("renders a Trækker order with all addons in the correct order regardless of API order", async () => { + // The scenario from the bug report: Trækker + Trailer + Dolly + Spot-free + Undervognsskyld. + // The API previously returned rows in undefined order, which made the FE render only + // Trailer/Dolly on top of the table and bury the primary Trækker below them. With the + // defensive sort, Trækker renders first and every addon renders after it. + const primary = buildItem({ id: 501, product_id: 51, name: "TraekkerBugRepro" }); + const trailerAddon = buildItem({ id: 502, product_id: 52, name: "TrailerAddonBugRepro", related_item_id: 501 }); + const dollyAddon = buildItem({ id: 503, product_id: 53, name: "DollyAddonBugRepro", related_item_id: 501 }); + const spotFreeAddon = buildItem({ id: 504, product_id: 54, name: "SpotFreeAddonBugRepro", related_item_id: 501 }); + const undervognAddon = buildItem({ id: 505, product_id: 55, name: "UndervognAddonBugRepro", related_item_id: 501 }); + + // Worst case: all addons before their primary. + const apiOrderItems = [trailerAddon, dollyAddon, spotFreeAddon, undervognAddon, primary]; + + requestMock.mockResolvedValue({ data: { data: apiOrderItems } }); + + const wrapper = mount(OrderContentTable, { + props: { orderId: 9005 }, + global: { + plugins: [i18n], + stubs: { + InvoicingPeriodFlagList: true, + }, + }, + }); + + await flushPromises(); + await flushPromises(); + + const rows = collectRenderedRows(wrapper); + const primaryIndex = indexOfRow(rows, (row) => row.name === "TraekkerBugRepro"); + expect(primaryIndex).toBeGreaterThanOrEqual(0); + + ["TrailerAddonBugRepro", "DollyAddonBugRepro", "SpotFreeAddonBugRepro", "UndervognAddonBugRepro"].forEach( + (name) => { + const idx = indexOfRow(rows, (row) => row.name === name && row.isAddon); + expect(idx).toBeGreaterThan(primaryIndex); + } + ); + + wrapper.unmount(); + }); +});