Defensive FE sort in OrderContentTable.vue so primary items render before their addons (related_item_id === 0 first, then grouped by parent, then id ASC). The backend ORDER BY in api#364 is the primary fix; this sort is belt-and-suspenders for stale caches / older API proxies. Pinned with tests/unit/order-content-table-addon-ordering.spec.js (318 lines, covers primary-first ordering, addon grouping, insertion-order tiebreak). Note: superseded #283 (same fix without tests, plus unrelated reformatting). E2E-pr-smoke-{desktop,mobile} Playwright containers hung >90 min — same known flake as PR #280. Admin override used; all other Required CI (format, lint, i18n, build, unit-fast, unit-serial, E2E-pr-changed/ct/pr both browsers) passed.
319 lines
11 KiB
JavaScript
319 lines
11 KiB
JavaScript
// @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 "+ <name>".
|
|
// 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();
|
|
});
|
|
});
|