+
@@ -2940,6 +3346,17 @@ const nodeActionWheelProps = (node: TreeNode) => {
gap: 0.35rem;
}
+.invoice-period-tree-toolbar__controls {
+ align-items: center;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+}
+
+.invoice-period-tree-toggle-all {
+ min-width: 8.5rem;
+}
+
.invoice-period-tree-actions-dropdown {
flex: 0 0 auto;
}
@@ -3379,6 +3796,15 @@ const nodeActionWheelProps = (node: TreeNode) => {
display: block;
}
+ .invoice-period-tree-toolbar__controls,
+ .invoice-period-tree-toggle-all {
+ width: 100%;
+ }
+
+ .invoice-period-tree-toolbar__controls .tag {
+ width: fit-content;
+ }
+
.invoice-period-tree-toolbar__actions {
margin-top: 0.5rem;
}
diff --git a/tests/e2e/invoicing-period.smoke.spec.js b/tests/e2e/invoicing-period.smoke.spec.js
index 9f3e25d1..ff776552 100644
--- a/tests/e2e/invoicing-period.smoke.spec.js
+++ b/tests/e2e/invoicing-period.smoke.spec.js
@@ -1382,6 +1382,77 @@ test.describe("Invoicing period tab", () => {
.toBe(true);
});
+ test("@smoke @pr period review expands and collapses the selected customer's complete object tree", async ({
+ page,
+ }) => {
+ await page.setViewportSize({ width: 1280, height: 900 });
+ const orderItemRequests = [];
+ page.on("request", (request) => {
+ if (request.method() === "GET" && new URL(request.url()).pathname.endsWith("/order/items")) {
+ orderItemRequests.push(request.url());
+ }
+ });
+
+ await openPeriodView(page, {
+ payloadFactory: createObjectTreePeriodPayload,
+ beforeGoto: routeObjectTreeOrderEndpoints,
+ });
+ await page.getByTestId("invoicing-period-view-selector-all").click();
+ await expect(page.getByTestId("invoicing-period-customer-expanded-4101")).toBeVisible();
+
+ const toggleAll = page.getByTestId("invoice-period-tree-toggle-all");
+ const firstInvoiceCollection = page.getByTestId("invoice-period-tree-node-collected_order_invoice:3001");
+ await expect(toggleAll).toBeVisible();
+ await expect(toggleAll).toBeEnabled();
+ await expect(toggleAll).toContainText(/Udfold alle|Expand all/i);
+ const toggleBox = await toggleAll.boundingBox();
+ const collectionBox = await firstInvoiceCollection.boundingBox();
+ expect(toggleBox).not.toBeNull();
+ expect(collectionBox).not.toBeNull();
+ expect(toggleBox.y + toggleBox.height).toBeLessThanOrEqual(collectionBox.y);
+ await toggleAll.click();
+
+ await expect(page.getByTestId("invoice-period-tree-node-order:9001")).toBeVisible();
+ await expect(page.getByTestId("invoice-period-tree-node-order_item:7701")).toBeVisible();
+ await expect(toggleAll).toContainText(/Fold alle sammen|Collapse all/i);
+ await expect(toggleAll).toHaveAttribute("aria-pressed", "true");
+ await expect
+ .poll(() =>
+ page
+ .getByTestId("invoice-period-object-tree")
+ .locator('[role="treeitem"][aria-expanded]')
+ .evaluateAll(
+ (nodes) => nodes.length > 0 && nodes.every((node) => node.getAttribute("aria-expanded") === "true")
+ )
+ )
+ .toBe(true);
+
+ const firstExpansionRequestCount = orderItemRequests.length;
+ expect(firstExpansionRequestCount).toBeGreaterThan(0);
+ await toggleAll.click();
+ await expect(toggleAll).toContainText(/Udfold alle|Expand all/i);
+ await expect(toggleAll).toHaveAttribute("aria-pressed", "false");
+ await expect(page.locator('[data-node-key="collected_order_invoice:3001"]')).toHaveAttribute(
+ "aria-expanded",
+ "false"
+ );
+
+ await toggleAll.click();
+ await expect(toggleAll).toContainText(/Fold alle sammen|Collapse all/i);
+ expect(orderItemRequests).toHaveLength(firstExpansionRequestCount);
+
+ for (const viewport of [
+ { width: 820, height: 1180 },
+ { width: 390, height: 844 },
+ ]) {
+ await page.setViewportSize(viewport);
+ await expect(toggleAll).toBeVisible();
+ await expect
+ .poll(() => page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth))
+ .toBe(true);
+ }
+ });
+
test("@smoke period view opens Selvvask import and attaching view", async ({ page }) => {
const usageOrderRequests = [];
const fastLinkRequests = [];
@@ -1913,6 +1984,12 @@ test.describe("Invoicing period tab", () => {
"quantity"
);
const priceBox = await getBoundingBox(page.getByTestId("invoice-period-tree-field-order_item:7701-price"), "price");
+ const firstOrderItem = page.getByTestId("invoice-period-tree-node-order_item:7701");
+ await expect(firstOrderItem.locator(".invoice-period-tree-node__subtitle")).toHaveCount(0);
+ await expect(page.getByTestId("invoice-period-tree-field-order_item:7701-quantity")).toContainText(
+ /Antal|Quantity/i
+ );
+ await expect(page.getByTestId("invoice-period-tree-field-order_item:7701-price")).toContainText(/Pris|Price/i);
expect(quantityBox.x + quantityBox.width).toBeLessThanOrEqual(priceBox.x + 2);
const alignedAmountBoxes = await Promise.all([
getBoundingBox(
diff --git a/tests/unit/buefy-tree-selection.spec.js b/tests/unit/buefy-tree-selection.spec.js
index 7d085070..8eb52f91 100644
--- a/tests/unit/buefy-tree-selection.spec.js
+++ b/tests/unit/buefy-tree-selection.spec.js
@@ -93,4 +93,97 @@ describe("BuefyTree checkbox selection", () => {
expect(wrapper.emitted("update:checkedKeys")?.at(-1)?.[0]).toHaveLength(1000);
expect(wrapper.findAll('[data-node-key^="order:"]')).toHaveLength(100);
});
+
+ it("recursively expands lazy branches, reveals every child, and reuses the cache after collapse", async () => {
+ let activeLoads = 0;
+ let maxActiveLoads = 0;
+ const load = vi.fn(async (node) => {
+ activeLoads += 1;
+ maxActiveLoads = Math.max(maxActiveLoads, activeLoads);
+ await Promise.resolve();
+ activeLoads -= 1;
+
+ if (node.id === "root") {
+ return [
+ { id: "branch:a", label: "Branch A", isLeaf: false },
+ { id: "branch:b", label: "Branch B", isLeaf: false },
+ ];
+ }
+ if (node.id === "branch:a") {
+ return [
+ { id: "leaf:a1", label: "Leaf A1", isLeaf: true },
+ { id: "leaf:a2", label: "Leaf A2", isLeaf: true },
+ ];
+ }
+ if (node.id === "branch:b") {
+ return [{ id: "branch:b1", label: "Branch B1", isLeaf: false }];
+ }
+ if (node.id === "branch:b1") {
+ return [{ id: "leaf:b1", label: "Leaf B1", isLeaf: true }];
+ }
+ return [];
+ });
+ const wrapper = mount(BuefyTree, {
+ props: {
+ data: [{ id: "root", label: "Root", isLeaf: false }],
+ lazy: true,
+ load,
+ progressiveBatchSize: 1,
+ },
+ });
+
+ const [firstResult, duplicateResult] = await Promise.all([wrapper.vm.expandAll(), wrapper.vm.expandAll()]);
+ await flushPromises();
+
+ expect(firstResult).toEqual(duplicateResult);
+ expect(firstResult.cancelled).toBe(false);
+ expect(firstResult.failedKeys).toEqual([]);
+ expect(firstResult.expandedKeys).toEqual(["root", "branch:a", "branch:b", "branch:b1"]);
+ expect(load).toHaveBeenCalledTimes(4);
+ expect(maxActiveLoads).toBeGreaterThan(1);
+ expect(maxActiveLoads).toBeLessThanOrEqual(4);
+ expect(wrapper.findAll('[data-node-key^="branch:"]')).toHaveLength(3);
+ expect(wrapper.findAll('[data-node-key^="leaf:"]')).toHaveLength(3);
+
+ wrapper.vm.collapseAll();
+ await flushPromises();
+ expect(wrapper.findAll('[data-node-key^="branch:"]')).toHaveLength(0);
+
+ await wrapper.vm.expandAll();
+ await flushPromises();
+ expect(load).toHaveBeenCalledTimes(4);
+ expect(wrapper.findAll('[data-node-key^="leaf:"]')).toHaveLength(3);
+ });
+
+ it("continues expanding sibling branches when one lazy branch fails", async () => {
+ const load = vi.fn(async (node) => {
+ if (node.id === "root") {
+ return [
+ { id: "branch:good", label: "Good", isLeaf: false },
+ { id: "branch:bad", label: "Bad", isLeaf: false },
+ ];
+ }
+ if (node.id === "branch:bad") {
+ throw new Error("Branch failed");
+ }
+ return [{ id: "leaf:good", label: "Good leaf", isLeaf: true }];
+ });
+ const wrapper = mount(BuefyTree, {
+ props: {
+ data: [{ id: "root", label: "Root", isLeaf: false }],
+ lazy: true,
+ load,
+ },
+ });
+
+ const result = await wrapper.vm.expandAll();
+ await flushPromises();
+
+ expect(result.cancelled).toBe(false);
+ expect(result.failedKeys).toEqual(["branch:bad"]);
+ expect(result.expandedKeys).toEqual(["root", "branch:good", "branch:bad"]);
+ expect(wrapper.emitted("load-error")?.[0]?.[0]).toEqual(new Error("Branch failed"));
+ expect(wrapper.get('[data-node-key="leaf:good"]').text()).toContain("Good leaf");
+ expect(wrapper.get('[data-node-key="branch:bad"]').classes()).toContain("has-load-error");
+ });
});
diff --git a/tests/unit/invoicing-period-object-tree.spec.js b/tests/unit/invoicing-period-object-tree.spec.js
index 99d72ad4..81b2014e 100644
--- a/tests/unit/invoicing-period-object-tree.spec.js
+++ b/tests/unit/invoicing-period-object-tree.spec.js
@@ -331,6 +331,69 @@ describe("InvoicingPeriodObjectTree", () => {
expect(wrapper.text()).toContain("Premium wash");
});
+ it("shows order-item quantity and price once through the standard editable fields", async () => {
+ const wrapper = mountTree();
+
+ await expandNode(wrapper, "collected_order_invoice:3001");
+ await expandNode(wrapper, "category:3001:collection_orders");
+ await expandNode(wrapper, "order:9001");
+
+ const orderItem = wrapper.get('[data-testid="invoice-period-tree-node-order_item:501"]');
+ expect(orderItem.find(".invoice-period-tree-node__subtitle").exists()).toBe(false);
+
+ const quantity = wrapper.get('[data-testid="invoice-period-tree-field-order_item:501-quantity"]');
+ const price = wrapper.get('[data-testid="invoice-period-tree-field-order_item:501-price"]');
+ expect(quantity.text()).toContain("Antal");
+ expect(quantity.text()).toContain("2");
+ expect(price.text()).toContain("Pris");
+ expect(price.text()).toContain("120 DKK");
+ expect(quantity.find(".editable-table-column-stub").exists()).toBe(true);
+ expect(price.find(".editable-table-column-stub").exists()).toBe(true);
+ });
+
+ it("keeps a visible expand-all toggle and reuses loaded branches after collapse", async () => {
+ const wrapper = mountTree();
+ const toggle = () => wrapper.get('[data-testid="invoice-period-tree-toggle-all"]');
+
+ expect(wrapper.get('[data-testid="invoice-period-tree-toolbar"]').exists()).toBe(true);
+ expect(
+ wrapper.find('[data-testid="invoice-period-tree-toolbar"] .invoice-period-tree-toolbar__actions').exists()
+ ).toBe(false);
+ expect(toggle().text()).toContain("Udfold alle");
+ expect(toggle().attributes("aria-pressed")).toBe("false");
+
+ await toggle().trigger("click");
+ await flushPromises();
+
+ expect(wrapper.get('[data-node-key="collected_order_invoice:3001"]').classes()).toContain("is-expanded");
+ expect(wrapper.get('[data-node-key="category:3001:collection_orders"]').classes()).toContain("is-expanded");
+ expect(wrapper.get('[data-node-key="order:9001"]').classes()).toContain("is-expanded");
+ expect(wrapper.get('[data-node-key="category:9001:order_items"]').classes()).toContain("is-expanded");
+ expect(toggle().text()).toContain("Fold alle sammen");
+ expect(toggle().attributes("aria-pressed")).toBe("true");
+
+ const loadCallCount = mocks.request.mock.calls.length;
+ await toggle().trigger("click");
+ await flushPromises();
+ expect(wrapper.get('[data-node-key="collected_order_invoice:3001"]').classes()).not.toContain("is-expanded");
+ expect(toggle().text()).toContain("Udfold alle");
+
+ await toggle().trigger("click");
+ await flushPromises();
+ expect(mocks.request).toHaveBeenCalledTimes(loadCallCount);
+ expect(toggle().text()).toContain("Fold alle sammen");
+ });
+
+ it("fully expands lazy content by default for invoice-per-order views", async () => {
+ const wrapper = mountTree({ autoExpandAll: true });
+
+ await flushPromises();
+
+ expect(wrapper.get('[data-testid="invoice-period-tree-toggle-all"]').text()).toContain("Fold alle sammen");
+ expect(wrapper.get('[data-node-key="order:9001"]').classes()).toContain("is-expanded");
+ expect(wrapper.get('[data-node-key="category:9001:order_items"]').classes()).toContain("is-expanded");
+ });
+
it("renders row action wheels for actionable collection, order, and order item nodes", async () => {
const wrapper = mountTree();
diff --git a/tests/unit/superuser-invoices-view.spec.js b/tests/unit/superuser-invoices-view.spec.js
index 589a4333..a13772ad 100644
--- a/tests/unit/superuser-invoices-view.spec.js
+++ b/tests/unit/superuser-invoices-view.spec.js
@@ -651,6 +651,9 @@ describe("Periode tab contract", () => {
expect(buefyTreeSource).toContain("progressiveBatchSize");
expect(buefyTreeSource).toContain("visibleChildrenOf");
expect(buefyTreeSource).toContain("remainingChildrenCount");
+ expect(buefyTreeSource).toContain("const expandAll =");
+ expect(buefyTreeSource).toContain("EXPAND_ALL_CONCURRENCY = 4");
+ expect(buefyTreeSource).toContain("defineExpose");
expect(buefyTreeNodeSource).toContain(' tree.retryLoad(node)"');
expect(buefyTreeNodeSource).toContain("b-tree-load-more__button");
@@ -669,6 +672,7 @@ describe("Periode tab contract", () => {
expect(periodTreeNodeServiceSource).toContain("hasWashCertificateOrderItem");
expect(periodObjectTreeSource).toContain("fetchAttachmentContent");
expect(periodObjectTreeSource).toContain('data-testid="invoice-period-tree-toolbar"');
+ expect(periodObjectTreeSource).toContain('data-testid="invoice-period-tree-toggle-all"');
expect(periodObjectTreeSource).toContain("`invoice-period-tree-actions-dropdown-${group.type}`");
});
@@ -742,6 +746,8 @@ describe("Periode tab contract", () => {
it("defines mixed-selection action menu locale labels", () => {
localeMessages.forEach(({ messages }) => {
expect(messages.invoicing_period.object_tree.buttons.actions).toBeTruthy();
+ expect(messages.invoicing_period.object_tree.buttons.collapse_all).toBeTruthy();
+ expect(messages.invoicing_period.object_tree.buttons.expand_all).toBeTruthy();
expect(messages.invoicing_period.object_tree.selection.no_actions).toBeTruthy();
});
});