diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py index 52befbff8f..0b34dee851 100644 --- a/InvenTree/InvenTree/helpers.py +++ b/InvenTree/InvenTree/helpers.py @@ -68,28 +68,10 @@ def constructPathString(path, max_chars=250): pathstring = '/'.join(path) - idx = 0 - # Replace middle elements to limit the pathstring if len(pathstring) > max_chars: - mid = len(path) // 2 - path_l = path[0:mid] - path_r = path[mid:] - - # Ensure the pathstring length is limited - while len(pathstring) > max_chars: - - # Remove an element from the list - if idx % 2 == 0: - path_l = path_l[:-1] - else: - path_r = path_r[1:] - - subpath = path_l + ['...'] + path_r - - pathstring = '/'.join(subpath) - - idx += 1 + n = int(max_chars / 2 - 2) + pathstring = pathstring[:n] + "..." + pathstring[-n:] return pathstring diff --git a/InvenTree/InvenTree/models.py b/InvenTree/InvenTree/models.py index 61fab96f40..5a7491b610 100644 --- a/InvenTree/InvenTree/models.py +++ b/InvenTree/InvenTree/models.py @@ -516,8 +516,18 @@ class InvenTreeTree(MPTTModel): ) if pathstring != self.pathstring: + + if 'force_insert' in kwargs: + del kwargs['force_insert'] + + kwargs['force_update'] = True + self.pathstring = pathstring - super().save(force_update=True) + super().save(*args, **kwargs) + + # Ensure that the pathstring changes are propagated down the tree also + for child in self.get_children(): + child.save(*args, **kwargs) class Meta: """Metaclass defines extra model properties.""" diff --git a/InvenTree/config_template.yaml b/InvenTree/config_template.yaml index 549f2f011c..538a832f21 100644 --- a/InvenTree/config_template.yaml +++ b/InvenTree/config_template.yaml @@ -126,10 +126,10 @@ cors: # - https://sub.example.com # MEDIA_ROOT is the local filesystem location for storing uploaded files -# media_root: '/home/inventree/data/media' +#media_root: '/home/inventree/data/media' # STATIC_ROOT is the local filesystem location for storing static files -# static_root: '/home/inventree/data/static' +#static_root: '/home/inventree/data/static' # Background worker options background: diff --git a/InvenTree/part/test_category.py b/InvenTree/part/test_category.py index edd85b4f95..c02aa1b6bd 100644 --- a/InvenTree/part/test_category.py +++ b/InvenTree/part/test_category.py @@ -118,7 +118,7 @@ class CategoryTest(TestCase): self.assertTrue(len(child.path), 26) self.assertEqual( child.pathstring, - "Cat/AAAAAAAAAA/BBBBBBBBBB/CCCCCCCCCC/DDDDDDDDDD/EEEEEEEEEE/FFFFFFFFFF/GGGGGGGGGG/HHHHHHHHHH/IIIIIIIIII/JJJJJJJJJJ/.../OOOOOOOOOO/PPPPPPPPPP/QQQQQQQQQQ/RRRRRRRRRR/SSSSSSSSSS/TTTTTTTTTT/UUUUUUUUUU/VVVVVVVVVV/WWWWWWWWWW/XXXXXXXXXX/YYYYYYYYYY/ZZZZZZZZZZ" + "Cat/AAAAAAAAAA/BBBBBBBBBB/CCCCCCCCCC/DDDDDDDDDD/EEEEEEEEEE/FFFFFFFFFF/GGGGGGGGGG/HHHHHHHHHH/IIIIIIIIII/JJJJJJJJJJ/KKKKKKKKK...OO/PPPPPPPPPP/QQQQQQQQQQ/RRRRRRRRRR/SSSSSSSSSS/TTTTTTTTTT/UUUUUUUUUU/VVVVVVVVVV/WWWWWWWWWW/XXXXXXXXXX/YYYYYYYYYY/ZZZZZZZZZZ" ) self.assertTrue(len(child.pathstring) <= 250) diff --git a/InvenTree/stock/tests.py b/InvenTree/stock/tests.py index 5e2fd72761..cf0773b246 100644 --- a/InvenTree/stock/tests.py +++ b/InvenTree/stock/tests.py @@ -48,6 +48,82 @@ class StockTestBase(InvenTreeTestCase): class StockTest(StockTestBase): """Tests to ensure that the stock location tree functions correcly.""" + def test_pathstring(self): + """Check that pathstring updates occur as expected""" + + a = StockLocation.objects.create(name="A") + b = StockLocation.objects.create(name="B", parent=a) + c = StockLocation.objects.create(name="C", parent=b) + d = StockLocation.objects.create(name="D", parent=c) + + def refresh(): + a.refresh_from_db() + b.refresh_from_db() + c.refresh_from_db() + d.refresh_from_db() + + # Initial checks + self.assertEqual(a.pathstring, "A") + self.assertEqual(b.pathstring, "A/B") + self.assertEqual(c.pathstring, "A/B/C") + self.assertEqual(d.pathstring, "A/B/C/D") + + c.name = "Cc" + c.save() + + refresh() + self.assertEqual(a.pathstring, "A") + self.assertEqual(b.pathstring, "A/B") + self.assertEqual(c.pathstring, "A/B/Cc") + self.assertEqual(d.pathstring, "A/B/Cc/D") + + b.name = "Bb" + b.save() + + refresh() + self.assertEqual(a.pathstring, "A") + self.assertEqual(b.pathstring, "A/Bb") + self.assertEqual(c.pathstring, "A/Bb/Cc") + self.assertEqual(d.pathstring, "A/Bb/Cc/D") + + a.name = "Aa" + a.save() + + refresh() + self.assertEqual(a.pathstring, "Aa") + self.assertEqual(b.pathstring, "Aa/Bb") + self.assertEqual(c.pathstring, "Aa/Bb/Cc") + self.assertEqual(d.pathstring, "Aa/Bb/Cc/D") + + d.name = "Dd" + d.save() + + refresh() + self.assertEqual(a.pathstring, "Aa") + self.assertEqual(b.pathstring, "Aa/Bb") + self.assertEqual(c.pathstring, "Aa/Bb/Cc") + self.assertEqual(d.pathstring, "Aa/Bb/Cc/Dd") + + # Test a really long name + # (it will be clipped to < 250 characters) + a.name = "A" * 100 + a.save() + b.name = "B" * 100 + b.save() + c.name = "C" * 100 + c.save() + d.name = "D" * 100 + d.save() + + refresh() + self.assertEqual(len(a.pathstring), 100) + self.assertEqual(len(b.pathstring), 201) + self.assertEqual(len(c.pathstring), 249) + self.assertEqual(len(d.pathstring), 249) + + self.assertTrue(d.pathstring.startswith("AAAAAAAA")) + self.assertTrue(d.pathstring.endswith("DDDDDDDD")) + def test_link(self): """Test the link URL field validation""" diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js index d9a74a725b..deecfb9213 100644 --- a/InvenTree/templates/js/translated/build.js +++ b/InvenTree/templates/js/translated/build.js @@ -1721,7 +1721,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { formatter: function(value, row) { if (row.location && row.location_detail) { - var text = row.location_detail.name; + var text = shortenString(row.location_detail.pathstring); var url = `/stock/location/${row.location}/`; return renderLink(text, url); diff --git a/InvenTree/templates/js/translated/helpers.js b/InvenTree/templates/js/translated/helpers.js index 5d0aed8bce..2f1619c906 100644 --- a/InvenTree/templates/js/translated/helpers.js +++ b/InvenTree/templates/js/translated/helpers.js @@ -13,8 +13,10 @@ sanitizeInputString, select2Thumbnail, setupNotesField, + shortenString, thumbnailImage yesNoLabel, + withTitle, */ function yesNoLabel(value) { @@ -36,6 +38,40 @@ function deleteButton(url, text='{% trans "Delete" %}') { } +/* + * Ensure a string does not exceed a maximum length. + * Useful for displaying long strings in tables, + * to ensure a very long string does not "overflow" the table + */ +function shortenString(input_string, options={}) { + + var max_length = options.max_length || 100; + + if (input_string == null) { + return null; + } + + input_string = input_string.toString(); + + // Easy option: input string is already short enough + if (input_string.length <= max_length) { + return input_string; + } + + var N = Math.floor(max_length / 2 - 1); + + var output_string = input_string.slice(0, N) + '...' + input_string.slice(-N); + + return output_string; +} + + +function withTitle(html, title, options={}) { + + return `
${html}
`; +} + + /* Format a decimal (floating point) number, to strip trailing zeros */ function formatDecimal(number, places=5) { @@ -214,24 +250,29 @@ function makeProgressBar(value, maximum, opts={}) { } +/* + * Render a URL for display + */ function renderLink(text, url, options={}) { if (url === null || url === undefined || url === '') { return text; } - var max_length = options.max_length || -1; + var max_length = options.max_length || 100; - // Shorten the displayed length if required - if ((max_length > 0) && (text.length > max_length)) { - var slice_length = (max_length - 3) / 2; - - var text_start = text.slice(0, slice_length); - var text_end = text.slice(-slice_length); - - text = `${text_start}...${text_end}`; + if (max_length > 0) { + text = shortenString(text, { + max_length: max_length, + }); } - return `${text}`; + var extras = ''; + + if (options.tooltip != false) { + extras += ` title="${url}"`; + } + + return `${text}`; } diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index 5b504d8dda..3e305301a3 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -1442,13 +1442,13 @@ function loadPartTable(table, url, options={}) { switchable: false, formatter: function(value, row) { - var name = row.full_name; + var name = shortenString(row.full_name); var display = imageHoverIcon(row.thumbnail) + renderLink(name, `/part/${row.pk}/`); display += makePartIcons(row); - return display; + return withTitle(display, row.full_name); } }; @@ -1463,11 +1463,13 @@ function loadPartTable(table, url, options={}) { title: '{% trans "Description" %}', formatter: function(value, row) { + var text = shortenString(value); + if (row.is_template) { - value = `${value}`; + text = `${text}`; } - return value; + return withTitle(text, row.description); } }); @@ -1476,8 +1478,11 @@ function loadPartTable(table, url, options={}) { field: 'category_detail', title: '{% trans "Category" %}', formatter: function(value, row) { + + var text = shortenString(row.category_detail.pathstring); + if (row.category) { - return renderLink(value.pathstring, `/part/category/${row.category}/`); + return withTitle(renderLink(text, `/part/category/${row.category}/`), row.category_detail.pathstring); } else { return '{% trans "No category" %}'; } @@ -1563,10 +1568,11 @@ function loadPartTable(table, url, options={}) { title: '{% trans "Link" %}', formatter: function(value) { return renderLink( - value, value, + value, + value, { - max_length: 32, remove_http: true, + tooltip: true, } ); } @@ -1976,7 +1982,7 @@ function loadPartCategoryTable(table, options) { html += renderLink( value, - `/part/category/${row.pk}/` + `/part/category/${row.pk}/`, ); if (row.starred) { @@ -1991,6 +1997,9 @@ function loadPartCategoryTable(table, options) { title: '{% trans "Description" %}', switchable: true, sortable: false, + formatter: function(value) { + return withTitle(shortenString(value), value); + } }, { field: 'pathstring', @@ -1998,6 +2007,9 @@ function loadPartCategoryTable(table, options) { switchable: !tree_view, visible: !tree_view, sortable: true, + formatter: function(value) { + return withTitle(shortenString(value), value); + } }, { field: 'part_count', diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index b2933f5024..e047590c7a 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -1606,7 +1606,7 @@ function locationDetail(row, showLink=true) { text = '{% trans "Assigned to Sales Order" %}'; url = `/order/sales-order/${row.sales_order}/`; } else if (row.location && row.location_detail) { - text = row.location_detail.pathstring; + text = shortenString(row.location_detail.pathstring); url = `/stock/location/${row.location}/`; } else { text = '{% trans "No stock location set" %}'; @@ -1707,7 +1707,7 @@ function loadStockTable(table, options) { var thumb = row.part_detail.thumbnail; var name = row.part_detail.full_name; - var html = imageHoverIcon(thumb) + renderLink(name, url); + var html = imageHoverIcon(thumb) + renderLink(shortenString(name), url); html += makePartIcons(row.part_detail); @@ -1728,7 +1728,8 @@ function loadStockTable(table, options) { visible: params['part_detail'], switchable: params['part_detail'], formatter: function(value, row) { - return row.part_detail.IPN; + var ipn = row.part_detail.IPN; + return withTitle(shortenString(ipn), ipn); }, }; @@ -1744,7 +1745,8 @@ function loadStockTable(table, options) { visible: params['part_detail'], switchable: params['part_detail'], formatter: function(value, row) { - return row.part_detail.description; + var description = row.part_detail.description; + return withTitle(shortenString(description), description); } }); @@ -2420,12 +2422,18 @@ function loadStockLocationTable(table, options) { title: '{% trans "Description" %}', switchable: true, sortable: false, + formatter: function(value) { + return withTitle(shortenString(value), value); + } }, { field: 'pathstring', title: '{% trans "Path" %}', switchable: true, sortable: true, + formatter: function(value) { + return withTitle(shortenString(value), value); + } }, { field: 'items', diff --git a/InvenTree/templates/js/translated/tables.js b/InvenTree/templates/js/translated/tables.js index 90e5f3f324..807051e65f 100644 --- a/InvenTree/templates/js/translated/tables.js +++ b/InvenTree/templates/js/translated/tables.js @@ -169,42 +169,6 @@ function downloadTableData(table, opts={}) { - -/** - * Render a URL for display - * @param {String} text - * @param {String} url - * @param {object} options - * @returns link text - */ -function renderLink(text, url, options={}) { - if (url === null || url === undefined || url === '') { - return text; - } - - var max_length = options.max_length || -1; - - var extra = ''; - - if (options.download) { - var fn = url.split('/').at(-1); - extra += ` download='${fn}'`; - } - - // Shorten the displayed length if required - if ((max_length > 0) && (text.length > max_length)) { - var slice_length = (max_length - 3) / 2; - - var text_start = text.slice(0, slice_length); - var text_end = text.slice(-slice_length); - - text = `${text_start}...${text_end}`; - } - - return `${text}`; -} - - function enableButtons(elements, enabled) { for (let item of elements) { $(item).prop('disabled', !enabled);