mirror of
https://github.com/inventree/InvenTree.git
synced 2026-02-13 09:47:09 +00:00
[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 <code@mjmair.com>
This commit is contained in:
@@ -1,11 +1,14 @@
|
|||||||
"""InvenTree API version information."""
|
"""InvenTree API version information."""
|
||||||
|
|
||||||
# InvenTree API version
|
# 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."""
|
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||||
|
|
||||||
INVENTREE_API_TEXT = """
|
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
|
v450 -> 2026-02-10 : https://github.com/inventree/InvenTree/pull/11260
|
||||||
- Adds "part" field to the StockItemTracking model and API endpoints
|
- Adds "part" field to the StockItemTracking model and API endpoints
|
||||||
- Additional filtering options for the StockItemTracking API endpoint
|
- Additional filtering options for the StockItemTracking API endpoint
|
||||||
|
|||||||
@@ -731,6 +731,48 @@ class InvenTreeAPITestCase(
|
|||||||
"""Assert that dictionary 'a' is a subset of dictionary 'b'."""
|
"""Assert that dictionary 'a' is a subset of dictionary 'b'."""
|
||||||
self.assertEqual(b, b | a)
|
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(
|
def run_output_test(
|
||||||
self,
|
self,
|
||||||
url: str,
|
url: str,
|
||||||
|
|||||||
@@ -346,6 +346,8 @@ class BuildList(
|
|||||||
filter_backends = SEARCH_ORDER_FILTER_ALIAS
|
filter_backends = SEARCH_ORDER_FILTER_ALIAS
|
||||||
ordering_fields = [
|
ordering_fields = [
|
||||||
'reference',
|
'reference',
|
||||||
|
'part',
|
||||||
|
'IPN',
|
||||||
'part__name',
|
'part__name',
|
||||||
'status',
|
'status',
|
||||||
'creation_date',
|
'creation_date',
|
||||||
@@ -364,6 +366,8 @@ class BuildList(
|
|||||||
ordering_field_aliases = {
|
ordering_field_aliases = {
|
||||||
'reference': ['reference_int', 'reference'],
|
'reference': ['reference_int', 'reference'],
|
||||||
'project_code': ['project_code__code'],
|
'project_code': ['project_code__code'],
|
||||||
|
'part': ['part__name'],
|
||||||
|
'IPN': ['part__IPN'],
|
||||||
}
|
}
|
||||||
ordering = '-reference'
|
ordering = '-reference'
|
||||||
search_fields = [
|
search_fields = [
|
||||||
|
|||||||
@@ -197,9 +197,17 @@ class ManufacturerPartList(
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
filterset_class = ManufacturerPartFilter
|
filterset_class = ManufacturerPartFilter
|
||||||
filter_backends = SEARCH_ORDER_FILTER
|
filter_backends = SEARCH_ORDER_FILTER_ALIAS
|
||||||
output_options = ManufacturerOutputOptions
|
output_options = ManufacturerOutputOptions
|
||||||
|
|
||||||
|
ordering_fields = ['part', 'IPN', 'MPN', 'manufacturer']
|
||||||
|
|
||||||
|
ordering_field_aliases = {
|
||||||
|
'part': 'part__name',
|
||||||
|
'IPN': 'part__IPN',
|
||||||
|
'manufacturer': 'manufacturer__name',
|
||||||
|
}
|
||||||
|
|
||||||
search_fields = [
|
search_fields = [
|
||||||
'manufacturer__name',
|
'manufacturer__name',
|
||||||
'description',
|
'description',
|
||||||
@@ -354,12 +362,13 @@ class SupplierPartList(
|
|||||||
output_options = SupplierPartOutputOptions
|
output_options = SupplierPartOutputOptions
|
||||||
|
|
||||||
ordering_fields = [
|
ordering_fields = [
|
||||||
'SKU',
|
|
||||||
'part',
|
'part',
|
||||||
'supplier',
|
'supplier',
|
||||||
'manufacturer',
|
'manufacturer',
|
||||||
'active',
|
'active',
|
||||||
|
'IPN',
|
||||||
'MPN',
|
'MPN',
|
||||||
|
'SKU',
|
||||||
'packaging',
|
'packaging',
|
||||||
'pack_quantity',
|
'pack_quantity',
|
||||||
'in_stock',
|
'in_stock',
|
||||||
@@ -370,8 +379,9 @@ class SupplierPartList(
|
|||||||
'part': 'part__name',
|
'part': 'part__name',
|
||||||
'supplier': 'supplier__name',
|
'supplier': 'supplier__name',
|
||||||
'manufacturer': 'manufacturer_part__manufacturer__name',
|
'manufacturer': 'manufacturer_part__manufacturer__name',
|
||||||
'MPN': 'manufacturer_part__MPN',
|
|
||||||
'pack_quantity': ['pack_quantity_native', 'pack_quantity'],
|
'pack_quantity': ['pack_quantity_native', 'pack_quantity'],
|
||||||
|
'IPN': 'part__IPN',
|
||||||
|
'MPN': 'manufacturer_part__MPN',
|
||||||
}
|
}
|
||||||
|
|
||||||
search_fields = [
|
search_fields = [
|
||||||
|
|||||||
@@ -1051,6 +1051,7 @@ class SalesOrderLineItemList(
|
|||||||
'customer',
|
'customer',
|
||||||
'order',
|
'order',
|
||||||
'part',
|
'part',
|
||||||
|
'IPN',
|
||||||
'part__name',
|
'part__name',
|
||||||
'quantity',
|
'quantity',
|
||||||
'allocated',
|
'allocated',
|
||||||
@@ -1063,6 +1064,7 @@ class SalesOrderLineItemList(
|
|||||||
ordering_field_aliases = {
|
ordering_field_aliases = {
|
||||||
'customer': 'order__customer__name',
|
'customer': 'order__customer__name',
|
||||||
'part': 'part__name',
|
'part': 'part__name',
|
||||||
|
'IPN': 'part__IPN',
|
||||||
'order': 'order__reference',
|
'order': 'order__reference',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1672,11 +1674,24 @@ class ReturnOrderLineItemList(
|
|||||||
|
|
||||||
filterset_class = ReturnOrderLineItemFilter
|
filterset_class = ReturnOrderLineItemFilter
|
||||||
|
|
||||||
filter_backends = SEARCH_ORDER_FILTER
|
filter_backends = SEARCH_ORDER_FILTER_ALIAS
|
||||||
|
|
||||||
output_options = ReturnOrderLineItemOutputOptions
|
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 = [
|
search_fields = [
|
||||||
'item__serial',
|
'item__serial',
|
||||||
|
|||||||
@@ -1263,7 +1263,9 @@ class StockList(
|
|||||||
filter_backends = SEARCH_ORDER_FILTER_ALIAS
|
filter_backends = SEARCH_ORDER_FILTER_ALIAS
|
||||||
|
|
||||||
ordering_field_aliases = {
|
ordering_field_aliases = {
|
||||||
|
'part': 'part__name',
|
||||||
'location': 'location__pathstring',
|
'location': 'location__pathstring',
|
||||||
|
'IPN': 'part__IPN',
|
||||||
'SKU': 'supplier_part__SKU',
|
'SKU': 'supplier_part__SKU',
|
||||||
'MPN': 'supplier_part__manufacturer_part__MPN',
|
'MPN': 'supplier_part__manufacturer_part__MPN',
|
||||||
'stock': ['quantity', 'serial_int', 'serial'],
|
'stock': ['quantity', 'serial_int', 'serial'],
|
||||||
@@ -1272,6 +1274,7 @@ class StockList(
|
|||||||
ordering_fields = [
|
ordering_fields = [
|
||||||
'batch',
|
'batch',
|
||||||
'location',
|
'location',
|
||||||
|
'part',
|
||||||
'part__name',
|
'part__name',
|
||||||
'part__IPN',
|
'part__IPN',
|
||||||
'updated',
|
'updated',
|
||||||
@@ -1281,6 +1284,7 @@ class StockList(
|
|||||||
'quantity',
|
'quantity',
|
||||||
'stock',
|
'stock',
|
||||||
'status',
|
'status',
|
||||||
|
'IPN',
|
||||||
'SKU',
|
'SKU',
|
||||||
'MPN',
|
'MPN',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -68,6 +68,11 @@ class StockLocationTest(StockAPITestCase):
|
|||||||
# Add some stock locations
|
# Add some stock locations
|
||||||
StockLocation.objects.create(name='top', description='top category')
|
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):
|
def test_list(self):
|
||||||
"""Test the StockLocationList API endpoint."""
|
"""Test the StockLocationList API endpoint."""
|
||||||
test_cases = [
|
test_cases = [
|
||||||
@@ -565,6 +570,11 @@ class StockItemListTest(StockAPITestCase):
|
|||||||
# Return JSON data
|
# Return JSON data
|
||||||
return response.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):
|
def test_top_level_filtering(self):
|
||||||
"""Test filtering against "top level" stock location."""
|
"""Test filtering against "top level" stock location."""
|
||||||
# No filters, should return *all* items
|
# No filters, should return *all* items
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ export function BuildOrderTable({
|
|||||||
{
|
{
|
||||||
accessor: 'part_detail.IPN',
|
accessor: 'part_detail.IPN',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
|
ordering: 'IPN',
|
||||||
switchable: true,
|
switchable: true,
|
||||||
title: t`IPN`
|
title: t`IPN`
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -67,7 +67,8 @@ export function ManufacturerPartTable({
|
|||||||
{
|
{
|
||||||
accessor: 'part_detail.IPN',
|
accessor: 'part_detail.IPN',
|
||||||
title: t`IPN`,
|
title: t`IPN`,
|
||||||
sortable: false,
|
sortable: true,
|
||||||
|
ordering: 'IPN',
|
||||||
switchable: true
|
switchable: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -68,7 +68,8 @@ export function SupplierPartTable({
|
|||||||
{
|
{
|
||||||
accessor: 'part_detail.IPN',
|
accessor: 'part_detail.IPN',
|
||||||
title: t`IPN`,
|
title: t`IPN`,
|
||||||
sortable: false,
|
sortable: true,
|
||||||
|
ordering: 'IPN',
|
||||||
switchable: true
|
switchable: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -94,7 +95,6 @@ export function SupplierPartTable({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessor: 'MPN',
|
accessor: 'MPN',
|
||||||
|
|
||||||
sortable: true,
|
sortable: true,
|
||||||
title: t`MPN`,
|
title: t`MPN`,
|
||||||
render: (record: any) => record?.manufacturer_part_detail?.MPN
|
render: (record: any) => record?.manufacturer_part_detail?.MPN
|
||||||
|
|||||||
@@ -110,11 +110,13 @@ export default function ReturnOrderLineItemTable({
|
|||||||
const tableColumns: TableColumn[] = useMemo(() => {
|
const tableColumns: TableColumn[] = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
PartColumn({
|
PartColumn({
|
||||||
part: 'part_detail'
|
part: 'part_detail',
|
||||||
|
ordering: 'part'
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
accessor: 'part_detail.IPN',
|
accessor: 'part_detail.IPN',
|
||||||
sortable: false
|
sortable: true,
|
||||||
|
ordering: 'IPN'
|
||||||
},
|
},
|
||||||
DescriptionColumn({
|
DescriptionColumn({
|
||||||
accessor: 'part_detail.description'
|
accessor: 'part_detail.description'
|
||||||
@@ -123,6 +125,8 @@ export default function ReturnOrderLineItemTable({
|
|||||||
accessor: 'item_detail.serial',
|
accessor: 'item_detail.serial',
|
||||||
title: t`Quantity`,
|
title: t`Quantity`,
|
||||||
switchable: false,
|
switchable: false,
|
||||||
|
sortable: true,
|
||||||
|
ordering: 'stock',
|
||||||
render: (record: any) => {
|
render: (record: any) => {
|
||||||
if (record.item_detail.serial && record.quantity == 1) {
|
if (record.item_detail.serial && record.quantity == 1) {
|
||||||
return `# ${record.item_detail.serial}`;
|
return `# ${record.item_detail.serial}`;
|
||||||
|
|||||||
@@ -97,6 +97,8 @@ export default function SalesOrderLineItemTable({
|
|||||||
{
|
{
|
||||||
accessor: 'part_detail.IPN',
|
accessor: 'part_detail.IPN',
|
||||||
title: t`IPN`,
|
title: t`IPN`,
|
||||||
|
sortable: true,
|
||||||
|
ordering: 'IPN',
|
||||||
switchable: true
|
switchable: true
|
||||||
},
|
},
|
||||||
DescriptionColumn({
|
DescriptionColumn({
|
||||||
|
|||||||
@@ -67,7 +67,8 @@ function stockItemTableColumns({
|
|||||||
{
|
{
|
||||||
accessor: 'part_detail.IPN',
|
accessor: 'part_detail.IPN',
|
||||||
title: t`IPN`,
|
title: t`IPN`,
|
||||||
sortable: true
|
sortable: true,
|
||||||
|
ordering: 'IPN'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessor: 'part_detail.revision',
|
accessor: 'part_detail.revision',
|
||||||
|
|||||||
@@ -167,3 +167,16 @@ export const deletePart = async (name: string) => {
|
|||||||
expect(res.status()).toBe(204);
|
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');
|
||||||
|
};
|
||||||
|
|||||||
@@ -104,9 +104,10 @@ test('Spotlight - No Keys', async ({ browser }) => {
|
|||||||
await page.waitForTimeout(250);
|
await page.waitForTimeout(250);
|
||||||
|
|
||||||
// assert the nav headers are visible
|
// assert the nav headers are visible
|
||||||
await page.getByText('Navigation').waitFor();
|
await page.getByText('Navigation').first().waitFor();
|
||||||
await page.getByText('Documentation').waitFor();
|
await page.getByText('Documentation').first().waitFor();
|
||||||
await page.getByText('About').first().waitFor();
|
await page.getByText('About').first().waitFor();
|
||||||
|
|
||||||
await page
|
await page
|
||||||
.getByRole('button', { name: 'Notifications', exact: true })
|
.getByRole('button', { name: 'Notifications', exact: true })
|
||||||
.waitFor();
|
.waitFor();
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { test } from './baseFixtures.js';
|
|||||||
import {
|
import {
|
||||||
clearTableFilters,
|
clearTableFilters,
|
||||||
navigate,
|
navigate,
|
||||||
setTableChoiceFilter
|
setTableChoiceFilter,
|
||||||
|
toggleColumnSorting
|
||||||
} from './helpers.js';
|
} from './helpers.js';
|
||||||
import { doCachedLogin } from './login.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: 'Reference', exact: true }).click();
|
||||||
await page.getByRole('menuitem', { name: 'Project Code' }).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');
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user