From d24ba7965c9e8d94d1eb638c0fbe5b67632180c6 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 11 Feb 2026 17:52:21 +1100 Subject: [PATCH] [bug] Fix table ordering (#11277) * Additional filtering options for stock list * Fix ordering for stock table * Ordering fix for build order table * Ordering for supplier parts and manufacturer parts * SalesOrderLineItem: Order by IPN * ReturnOrderLineItem table: - Order by part name - Order by part IPN * Update API version to 451 Increment API version to 451 and update changelog. * Add playwright tests for column sorting * Add backend tests for API ordering --------- Co-authored-by: Matthias Mair --- .../InvenTree/InvenTree/api_version.py | 5 ++- src/backend/InvenTree/InvenTree/unit_test.py | 42 +++++++++++++++++++ src/backend/InvenTree/build/api.py | 4 ++ src/backend/InvenTree/company/api.py | 16 +++++-- src/backend/InvenTree/order/api.py | 19 ++++++++- src/backend/InvenTree/stock/api.py | 4 ++ src/backend/InvenTree/stock/test_api.py | 10 +++++ .../src/tables/build/BuildOrderTable.tsx | 1 + .../purchasing/ManufacturerPartTable.tsx | 3 +- .../tables/purchasing/SupplierPartTable.tsx | 4 +- .../tables/sales/ReturnOrderLineItemTable.tsx | 8 +++- .../tables/sales/SalesOrderLineItemTable.tsx | 2 + .../src/tables/stock/StockItemTable.tsx | 3 +- src/frontend/tests/helpers.ts | 13 ++++++ src/frontend/tests/pui_modals.spec.ts | 5 ++- src/frontend/tests/pui_tables.spec.ts | 25 ++++++++++- 16 files changed, 149 insertions(+), 15 deletions(-) diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index a844018e94..c9f3d2c652 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,14 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 450 +INVENTREE_API_VERSION = 451 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v451 -> 2026-02-10 : https://github.com/inventree/InvenTree/pull/11277 + - Adds sorting to multiple part related endpoints (part, IPN, ...) + v450 -> 2026-02-10 : https://github.com/inventree/InvenTree/pull/11260 - Adds "part" field to the StockItemTracking model and API endpoints - Additional filtering options for the StockItemTracking API endpoint diff --git a/src/backend/InvenTree/InvenTree/unit_test.py b/src/backend/InvenTree/InvenTree/unit_test.py index 54cde6afe9..bae1262297 100644 --- a/src/backend/InvenTree/InvenTree/unit_test.py +++ b/src/backend/InvenTree/InvenTree/unit_test.py @@ -731,6 +731,48 @@ class InvenTreeAPITestCase( """Assert that dictionary 'a' is a subset of dictionary 'b'.""" self.assertEqual(b, b | a) + def run_ordering_test( + self, url: str, ordering_field: str, params: Optional[dict] = None + ): + """Run a test to check that the results are ordered correctly. + + Arguments: + url: The URL to test + ordering_field: The field to order by (e.g. 'name') + params: Additional parameters to include in the request (e.g. filters) + + Process: + - Run a GET request against the provided URL with the appropriate ordering parameter + - Run a separate GET request with the opposite ordering parameter (e.g. '-name') + - Check that the results are ordered differently in each case + """ + query_params = {**(params or {})} + + pk_values = set() + + for ordering in [None, ordering_field, f'-{ordering_field}']: + response = self.get( + url, + data={**query_params, 'ordering': ordering} + if ordering + else query_params, + expected_code=200, + ) + + self.assertGreater( + len(response.data), + 1, + f'No data returned from {url} with ordering={ordering}', + ) + + pk_values.add(response.data[0]['pk']) + + self.assertGreater( + len(pk_values), + 1, + f"Ordering by '{ordering_field}' does not change the order of results at {url}", + ) + def run_output_test( self, url: str, diff --git a/src/backend/InvenTree/build/api.py b/src/backend/InvenTree/build/api.py index c60e4f2f05..4ecf9c12b7 100644 --- a/src/backend/InvenTree/build/api.py +++ b/src/backend/InvenTree/build/api.py @@ -346,6 +346,8 @@ class BuildList( filter_backends = SEARCH_ORDER_FILTER_ALIAS ordering_fields = [ 'reference', + 'part', + 'IPN', 'part__name', 'status', 'creation_date', @@ -364,6 +366,8 @@ class BuildList( ordering_field_aliases = { 'reference': ['reference_int', 'reference'], 'project_code': ['project_code__code'], + 'part': ['part__name'], + 'IPN': ['part__IPN'], } ordering = '-reference' search_fields = [ diff --git a/src/backend/InvenTree/company/api.py b/src/backend/InvenTree/company/api.py index cc58b4096f..6005a2eb99 100644 --- a/src/backend/InvenTree/company/api.py +++ b/src/backend/InvenTree/company/api.py @@ -197,9 +197,17 @@ class ManufacturerPartList( """ filterset_class = ManufacturerPartFilter - filter_backends = SEARCH_ORDER_FILTER + filter_backends = SEARCH_ORDER_FILTER_ALIAS output_options = ManufacturerOutputOptions + ordering_fields = ['part', 'IPN', 'MPN', 'manufacturer'] + + ordering_field_aliases = { + 'part': 'part__name', + 'IPN': 'part__IPN', + 'manufacturer': 'manufacturer__name', + } + search_fields = [ 'manufacturer__name', 'description', @@ -354,12 +362,13 @@ class SupplierPartList( output_options = SupplierPartOutputOptions ordering_fields = [ - 'SKU', 'part', 'supplier', 'manufacturer', 'active', + 'IPN', 'MPN', + 'SKU', 'packaging', 'pack_quantity', 'in_stock', @@ -370,8 +379,9 @@ class SupplierPartList( 'part': 'part__name', 'supplier': 'supplier__name', 'manufacturer': 'manufacturer_part__manufacturer__name', - 'MPN': 'manufacturer_part__MPN', 'pack_quantity': ['pack_quantity_native', 'pack_quantity'], + 'IPN': 'part__IPN', + 'MPN': 'manufacturer_part__MPN', } search_fields = [ diff --git a/src/backend/InvenTree/order/api.py b/src/backend/InvenTree/order/api.py index c59fc2c22a..096e8d1dfb 100644 --- a/src/backend/InvenTree/order/api.py +++ b/src/backend/InvenTree/order/api.py @@ -1051,6 +1051,7 @@ class SalesOrderLineItemList( 'customer', 'order', 'part', + 'IPN', 'part__name', 'quantity', 'allocated', @@ -1063,6 +1064,7 @@ class SalesOrderLineItemList( ordering_field_aliases = { 'customer': 'order__customer__name', 'part': 'part__name', + 'IPN': 'part__IPN', 'order': 'order__reference', } @@ -1672,11 +1674,24 @@ class ReturnOrderLineItemList( filterset_class = ReturnOrderLineItemFilter - filter_backends = SEARCH_ORDER_FILTER + filter_backends = SEARCH_ORDER_FILTER_ALIAS output_options = ReturnOrderLineItemOutputOptions - ordering_fields = ['reference', 'target_date', 'received_date'] + ordering_fields = [ + 'part', + 'IPN', + 'stock', + 'reference', + 'target_date', + 'received_date', + ] + + ordering_field_aliases = { + 'part': 'item__part__name', + 'IPN': 'item__part__IPN', + 'stock': ['item__quantity', 'item__serial_int', 'item__serial'], + } search_fields = [ 'item__serial', diff --git a/src/backend/InvenTree/stock/api.py b/src/backend/InvenTree/stock/api.py index 88d25a9130..14bf8b4824 100644 --- a/src/backend/InvenTree/stock/api.py +++ b/src/backend/InvenTree/stock/api.py @@ -1263,7 +1263,9 @@ class StockList( filter_backends = SEARCH_ORDER_FILTER_ALIAS ordering_field_aliases = { + 'part': 'part__name', 'location': 'location__pathstring', + 'IPN': 'part__IPN', 'SKU': 'supplier_part__SKU', 'MPN': 'supplier_part__manufacturer_part__MPN', 'stock': ['quantity', 'serial_int', 'serial'], @@ -1272,6 +1274,7 @@ class StockList( ordering_fields = [ 'batch', 'location', + 'part', 'part__name', 'part__IPN', 'updated', @@ -1281,6 +1284,7 @@ class StockList( 'quantity', 'stock', 'status', + 'IPN', 'SKU', 'MPN', ] diff --git a/src/backend/InvenTree/stock/test_api.py b/src/backend/InvenTree/stock/test_api.py index 58ab627374..e997a113a1 100644 --- a/src/backend/InvenTree/stock/test_api.py +++ b/src/backend/InvenTree/stock/test_api.py @@ -68,6 +68,11 @@ class StockLocationTest(StockAPITestCase): # Add some stock locations StockLocation.objects.create(name='top', description='top category') + def test_ordering(self): + """Test ordering options for the StockLocation list endpoint.""" + for ordering in ['name', 'pathstring', 'level', 'tree_id']: + self.run_ordering_test(self.list_url, ordering) + def test_list(self): """Test the StockLocationList API endpoint.""" test_cases = [ @@ -565,6 +570,11 @@ class StockItemListTest(StockAPITestCase): # Return JSON data return response.data + def test_ordering(self): + """Run ordering tests against the StockItem list endpoint.""" + for ordering in ['part', 'location', 'stock', 'status', 'IPN', 'MPN', 'SKU']: + self.run_ordering_test(self.list_url, ordering) + def test_top_level_filtering(self): """Test filtering against "top level" stock location.""" # No filters, should return *all* items diff --git a/src/frontend/src/tables/build/BuildOrderTable.tsx b/src/frontend/src/tables/build/BuildOrderTable.tsx index 4a3fcc7ccc..242a81ef4e 100644 --- a/src/frontend/src/tables/build/BuildOrderTable.tsx +++ b/src/frontend/src/tables/build/BuildOrderTable.tsx @@ -75,6 +75,7 @@ export function BuildOrderTable({ { accessor: 'part_detail.IPN', sortable: true, + ordering: 'IPN', switchable: true, title: t`IPN` }, diff --git a/src/frontend/src/tables/purchasing/ManufacturerPartTable.tsx b/src/frontend/src/tables/purchasing/ManufacturerPartTable.tsx index 01871f0eb4..d18f99dc87 100644 --- a/src/frontend/src/tables/purchasing/ManufacturerPartTable.tsx +++ b/src/frontend/src/tables/purchasing/ManufacturerPartTable.tsx @@ -67,7 +67,8 @@ export function ManufacturerPartTable({ { accessor: 'part_detail.IPN', title: t`IPN`, - sortable: false, + sortable: true, + ordering: 'IPN', switchable: true }, { diff --git a/src/frontend/src/tables/purchasing/SupplierPartTable.tsx b/src/frontend/src/tables/purchasing/SupplierPartTable.tsx index 9b819f9169..26b7a9d040 100644 --- a/src/frontend/src/tables/purchasing/SupplierPartTable.tsx +++ b/src/frontend/src/tables/purchasing/SupplierPartTable.tsx @@ -68,7 +68,8 @@ export function SupplierPartTable({ { accessor: 'part_detail.IPN', title: t`IPN`, - sortable: false, + sortable: true, + ordering: 'IPN', switchable: true }, { @@ -94,7 +95,6 @@ export function SupplierPartTable({ }, { accessor: 'MPN', - sortable: true, title: t`MPN`, render: (record: any) => record?.manufacturer_part_detail?.MPN diff --git a/src/frontend/src/tables/sales/ReturnOrderLineItemTable.tsx b/src/frontend/src/tables/sales/ReturnOrderLineItemTable.tsx index 51d01d1109..0f7684725d 100644 --- a/src/frontend/src/tables/sales/ReturnOrderLineItemTable.tsx +++ b/src/frontend/src/tables/sales/ReturnOrderLineItemTable.tsx @@ -110,11 +110,13 @@ export default function ReturnOrderLineItemTable({ const tableColumns: TableColumn[] = useMemo(() => { return [ PartColumn({ - part: 'part_detail' + part: 'part_detail', + ordering: 'part' }), { accessor: 'part_detail.IPN', - sortable: false + sortable: true, + ordering: 'IPN' }, DescriptionColumn({ accessor: 'part_detail.description' @@ -123,6 +125,8 @@ export default function ReturnOrderLineItemTable({ accessor: 'item_detail.serial', title: t`Quantity`, switchable: false, + sortable: true, + ordering: 'stock', render: (record: any) => { if (record.item_detail.serial && record.quantity == 1) { return `# ${record.item_detail.serial}`; diff --git a/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx b/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx index 6826ac2a7f..7b2cb4b440 100644 --- a/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx +++ b/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx @@ -97,6 +97,8 @@ export default function SalesOrderLineItemTable({ { accessor: 'part_detail.IPN', title: t`IPN`, + sortable: true, + ordering: 'IPN', switchable: true }, DescriptionColumn({ diff --git a/src/frontend/src/tables/stock/StockItemTable.tsx b/src/frontend/src/tables/stock/StockItemTable.tsx index 72b2854b3e..f8204cc71f 100644 --- a/src/frontend/src/tables/stock/StockItemTable.tsx +++ b/src/frontend/src/tables/stock/StockItemTable.tsx @@ -67,7 +67,8 @@ function stockItemTableColumns({ { accessor: 'part_detail.IPN', title: t`IPN`, - sortable: true + sortable: true, + ordering: 'IPN' }, { accessor: 'part_detail.revision', diff --git a/src/frontend/tests/helpers.ts b/src/frontend/tests/helpers.ts index 4cb02129d2..ce706470a5 100644 --- a/src/frontend/tests/helpers.ts +++ b/src/frontend/tests/helpers.ts @@ -167,3 +167,16 @@ export const deletePart = async (name: string) => { expect(res.status()).toBe(204); } }; + +// Click on the column sorting toggle +export const toggleColumnSorting = async (page: Page, columnName: string) => { + // Click on the column header to toggle sorting + const regex = new RegExp( + `^${columnName}\\s*(Not sorted|Sorted ascending|Sorted descending)$`, + 'i' + ); + + await page.getByRole('button', { name: regex }).click(); + await page.waitForTimeout(50); + await page.waitForLoadState('networkidle'); +}; diff --git a/src/frontend/tests/pui_modals.spec.ts b/src/frontend/tests/pui_modals.spec.ts index df67dcbfa2..83cc310bb8 100644 --- a/src/frontend/tests/pui_modals.spec.ts +++ b/src/frontend/tests/pui_modals.spec.ts @@ -104,9 +104,10 @@ test('Spotlight - No Keys', async ({ browser }) => { await page.waitForTimeout(250); // assert the nav headers are visible - await page.getByText('Navigation').waitFor(); - await page.getByText('Documentation').waitFor(); + await page.getByText('Navigation').first().waitFor(); + await page.getByText('Documentation').first().waitFor(); await page.getByText('About').first().waitFor(); + await page .getByRole('button', { name: 'Notifications', exact: true }) .waitFor(); diff --git a/src/frontend/tests/pui_tables.spec.ts b/src/frontend/tests/pui_tables.spec.ts index d6296e8688..a463481cdb 100644 --- a/src/frontend/tests/pui_tables.spec.ts +++ b/src/frontend/tests/pui_tables.spec.ts @@ -2,7 +2,8 @@ import { test } from './baseFixtures.js'; import { clearTableFilters, navigate, - setTableChoiceFilter + setTableChoiceFilter, + toggleColumnSorting } from './helpers.js'; import { doCachedLogin } from './login.js'; @@ -98,3 +99,25 @@ test('Tables - Columns', async ({ browser }) => { await page.getByRole('menuitem', { name: 'Reference', exact: true }).click(); await page.getByRole('menuitem', { name: 'Project Code' }).click(); }); + +test('Tables - Sorting', async ({ browser }) => { + // Go to the "stock list" page + const page = await doCachedLogin(browser, { + url: 'stock/location/index/stock-items', + username: 'steven', + password: 'wizardstaff' + }); + + // Stock table sorting + await toggleColumnSorting(page, 'Part'); + await toggleColumnSorting(page, 'IPN'); + await toggleColumnSorting(page, 'Stock'); + await toggleColumnSorting(page, 'Status'); + + // Purchase order sorting + await navigate(page, '/web/purchasing/index/purchaseorders'); + await toggleColumnSorting(page, 'Reference'); + await toggleColumnSorting(page, 'Supplier'); + await toggleColumnSorting(page, 'Order Status'); + await toggleColumnSorting(page, 'Line Items'); +});