From 2fb662080b6526e2d0f1727a3ebef68a4e10040c Mon Sep 17 00:00:00 2001
From: Ian Roddis <31021769+iroddis@users.noreply.github.com>
Date: Fri, 31 Jul 2026 09:52:09 -0300
Subject: [PATCH] Adding pervasive column sorting
---
.claude/settings.local.json | 19 +-
app/crud/augmented_part.py | 49 +++-
app/crud/imported_part.py | 46 ++-
app/static/css/style.css | 31 ++
app/static/js/sortable-tables.js | 77 +++++
app/templates/_scopes_table_card.html | 4 +-
.../admin/_attachment_point_names_table.html | 2 +-
.../admin/_attachment_point_row.html | 1 -
.../admin/_attachment_points_table.html | 3 +-
.../admin/_augmented_parts_table.html | 2 +
.../admin/_augmented_parts_table_card.html | 17 +-
.../admin/_clamp_connections_table.html | 2 +-
.../admin/_imported_parts_table.html | 2 +
.../admin/_imported_parts_table_card.html | 17 +-
app/templates/admin/_manufacturers_table.html | 4 +-
app/templates/admin/_part_colours_table.html | 2 +-
app/templates/admin/_part_types_table.html | 2 +-
app/templates/admin/_roles_table.html | 4 +-
app/templates/admin/_scope_models_table.html | 2 +-
app/templates/admin/_sort_macros.html | 13 +
.../admin/_static_connections_table.html | 2 +-
.../admin/_sub_part_types_table.html | 2 +-
app/templates/admin/_users_table.html | 4 +-
app/templates/admin/audit_log.html | 2 +-
app/templates/base.html | 1 +
app/web/admin.py | 267 +++++++++++++-----
tests/test_admin_sorting.py | 148 ++++++++++
27 files changed, 614 insertions(+), 111 deletions(-)
create mode 100644 app/static/js/sortable-tables.js
create mode 100644 app/templates/admin/_sort_macros.html
create mode 100644 tests/test_admin_sorting.py
diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index d309f38..f3fbedd 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -13,7 +13,24 @@
"Bash(docker build *)",
"Bash(git status *)",
"Bash(git -C /home/iroddis/dev/armament/fitguide status --short)",
- "Bash(git -C /home/iroddis/dev/armament/fitguide log --oneline -10)"
+ "Bash(git -C /home/iroddis/dev/armament/fitguide log --oneline -10)",
+ "Bash(node --check /Users/roddis/dev/armament/fitguide/app/static/js/sortable-tables.js)",
+ "Bash(sqlite3 /Users/roddis/dev/armament/fitguide/app.db \"select count\\(*\\) from users;\")",
+ "Bash(sqlite3 /Users/roddis/dev/armament/fitguide/app.db \"select count\\(*\\) from part_colours;\")",
+ "Bash(sqlite3 /Users/roddis/dev/armament/fitguide/app.db \"select count\\(*\\) from manufacturers;\")",
+ "Bash(curl -s -c /tmp/fg_cookies.txt -o /dev/null -w \"login:%{http_code}\\\\n\" -d \"email=admin@armament.com&password=changethis\" http://localhost:8000/login)",
+ "Bash(curl -s -b /tmp/fg_cookies.txt http://localhost:8000/admin/manufacturers)",
+ "Bash(curl -s -o /dev/null -w \"%{http_code} %{content_type}\\\\n\" http://localhost:8000/static/js/sortable-tables.js)",
+ "Bash(curl -s -b /tmp/fg_cookies.txt \"http://localhost:8000/admin/augmented-parts\")",
+ "Bash(curl -s -b /tmp/fg_cookies.txt \"http://localhost:8000/admin/augmented-parts/filter?sort=colour&dir=desc\")",
+ "Bash(curl -s -b /tmp/fg_cookies.txt \"http://localhost:8000/admin/roles\")",
+ "Bash(curl -s http://localhost:8000/scopes)",
+ "Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" http://localhost:8000/scopes)",
+ "Bash(curl -s -b /tmp/fg_cookies.txt http://localhost:8000/scopes)",
+ "Bash(lsof -ti:8000 -sTCP:LISTEN)",
+ "Bash(xargs -r kill)",
+ "Bash(rm -f /tmp/fg_cookies.txt /private/tmp/claude-501/-Users-roddis-dev-armament-fitguide/7873fd9c-2f2b-4583-877e-72fc88718f24/scratchpad/smoke_sort.py)",
+ "Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" http://localhost:8000/login)"
]
}
}
diff --git a/app/crud/augmented_part.py b/app/crud/augmented_part.py
index 7fce239..7f06de3 100644
--- a/app/crud/augmented_part.py
+++ b/app/crud/augmented_part.py
@@ -4,9 +4,47 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.models.associations import augmented_part_imported_parts
from app.models.augmented_part import AugmentedPart
from app.models.imported_part import ImportedPart
+from app.models.manufacturer import Manufacturer
+from app.models.part_colour import PartColour
+from app.models.part_type import PartType
+from app.models.scope_model import ScopeModel
+from app.models.sub_part_type import SubPartType
_RELATIONSHIPS = ["colour", "manufacturer", "scope_model", "part_type", "sub_part_type"]
+# Sortable columns for the admin list, keyed by the query-string "sort" value.
+# Columns backed by a related table's name require an outer join (the FK may be
+# nullable, e.g. scope_model_id) so unrelated rows still appear, ordered last.
+SORT_COLUMNS = {
+ "id": (AugmentedPart.id, None),
+ "sku": (AugmentedPart.sku, None),
+ "description": (AugmentedPart.description, None),
+ "colour": (PartColour.name, (PartColour, AugmentedPart.colour_id == PartColour.id)),
+ "manufacturer": (
+ Manufacturer.name,
+ (Manufacturer, AugmentedPart.manufacturer_id == Manufacturer.id),
+ ),
+ "scope_model": (
+ ScopeModel.name,
+ (ScopeModel, AugmentedPart.scope_model_id == ScopeModel.id),
+ ),
+ "part_type": (PartType.name, (PartType, AugmentedPart.part_type_id == PartType.id)),
+ "sub_part_type": (
+ SubPartType.name,
+ (SubPartType, AugmentedPart.sub_part_type_id == SubPartType.id),
+ ),
+}
+
+
+def _apply_sort(q, sort: str, direction: str):
+ column, join = SORT_COLUMNS.get(sort, SORT_COLUMNS["description"])
+ if join is not None:
+ table, cond = join
+ q = q.outerjoin(table, cond)
+ order = column.desc() if direction == "desc" else column.asc()
+ # AugmentedPart.id as a tiebreaker keeps paginated ordering stable.
+ return q.order_by(order, AugmentedPart.id)
+
async def get(db: AsyncSession, augmented_part_id: int) -> AugmentedPart | None:
return await db.get(AugmentedPart, augmented_part_id)
@@ -35,9 +73,7 @@ async def get_many(db: AsyncSession, ids: list[int]) -> list[AugmentedPart]:
"""Fetch augmented parts by id in one query (selectin relationships loaded)."""
if not ids:
return []
- result = await db.execute(
- select(AugmentedPart).where(AugmentedPart.id.in_(ids))
- )
+ result = await db.execute(select(AugmentedPart).where(AugmentedPart.id.in_(ids)))
return list(result.scalars().all())
@@ -49,6 +85,8 @@ async def list_filtered(
scope_model_id: int | None = None,
part_type_id: int | None = None,
sub_part_type_id: int | None = None,
+ sort: str = "description",
+ direction: str = "asc",
skip: int = 0,
limit: int = 100,
) -> list[AugmentedPart]:
@@ -65,9 +103,8 @@ async def list_filtered(
q = q.where(AugmentedPart.part_type_id == part_type_id)
if sub_part_type_id is not None:
q = q.where(AugmentedPart.sub_part_type_id == sub_part_type_id)
- result = await db.execute(
- q.order_by(AugmentedPart.description).offset(skip).limit(limit)
- )
+ q = _apply_sort(q, sort, direction)
+ result = await db.execute(q.offset(skip).limit(limit))
return list(result.scalars().all())
diff --git a/app/crud/imported_part.py b/app/crud/imported_part.py
index 8085277..1184aed 100644
--- a/app/crud/imported_part.py
+++ b/app/crud/imported_part.py
@@ -16,14 +16,37 @@ _FIELDS = frozenset(
}
)
+# Sortable columns for the admin list, keyed by the query-string "sort" value.
+SORT_COLUMNS = {
+ "InventoryID": ImportedPart.InventoryID,
+ "Description": ImportedPart.Description,
+ "TBXColour": ImportedPart.TBXColour,
+ "ProductMaxThreadDepthmm": ImportedPart.ProductMaxThreadDepthmm,
+ "ProductMaxThreadDepthin": ImportedPart.ProductMaxThreadDepthin,
+ "ProductODmm": ImportedPart.ProductODmm,
+ "ProductODin": ImportedPart.ProductODin,
+ "TBXThreadAttribute": ImportedPart.TBXThreadAttribute,
+}
+
+
+def _apply_sort(q, sort: str, direction: str):
+ column = SORT_COLUMNS.get(sort, ImportedPart.InventoryID)
+ order = column.desc() if direction == "desc" else column.asc()
+ return q.order_by(order, ImportedPart.InventoryID)
+
async def get(db: AsyncSession, inventory_id: str) -> ImportedPart | None:
return await db.get(ImportedPart, inventory_id)
-async def list_all(db: AsyncSession, skip: int = 0, limit: int = 500) -> list[ImportedPart]:
+async def list_all(
+ db: AsyncSession, skip: int = 0, limit: int = 500
+) -> list[ImportedPart]:
result = await db.execute(
- select(ImportedPart).order_by(ImportedPart.InventoryID).offset(skip).limit(limit)
+ select(ImportedPart)
+ .order_by(ImportedPart.InventoryID)
+ .offset(skip)
+ .limit(limit)
)
return list(result.scalars().all())
@@ -31,13 +54,16 @@ async def list_all(db: AsyncSession, skip: int = 0, limit: int = 500) -> list[Im
async def list_filtered(
db: AsyncSession,
description: str = "",
+ sort: str = "InventoryID",
+ direction: str = "asc",
skip: int = 0,
limit: int = 500,
) -> list[ImportedPart]:
q = select(ImportedPart)
if description.strip():
q = q.where(ImportedPart.Description.ilike(f"%{description.strip()}%"))
- result = await db.execute(q.order_by(ImportedPart.InventoryID).offset(skip).limit(limit))
+ q = _apply_sort(q, sort, direction)
+ result = await db.execute(q.offset(skip).limit(limit))
return list(result.scalars().all())
@@ -78,7 +104,11 @@ async def upsert_bulk(db: AsyncSession, records: list[dict]) -> tuple[int, int]:
return 0, 0
clean = [
- {k: str(v).strip() if v is not None else None for k, v in r.items() if k in _FIELDS}
+ {
+ k: str(v).strip() if v is not None else None
+ for k, v in r.items()
+ if k in _FIELDS
+ }
for r in records
if r.get("InventoryID")
]
@@ -88,7 +118,9 @@ async def upsert_bulk(db: AsyncSession, records: list[dict]) -> tuple[int, int]:
incoming_ids = {r["InventoryID"] for r in clean}
result = await db.execute(
- select(ImportedPart.InventoryID).where(ImportedPart.InventoryID.in_(incoming_ids))
+ select(ImportedPart.InventoryID).where(
+ ImportedPart.InventoryID.in_(incoming_ids)
+ )
)
existing_ids = {row[0] for row in result.all()}
@@ -103,7 +135,9 @@ async def upsert_bulk(db: AsyncSession, records: list[dict]) -> tuple[int, int]:
vals = {k: v for k, v in record.items() if k != "InventoryID"}
if vals:
await db.execute(
- update(ImportedPart).where(ImportedPart.InventoryID == inv_id).values(vals)
+ update(ImportedPart)
+ .where(ImportedPart.InventoryID == inv_id)
+ .values(vals)
)
await db.commit()
diff --git a/app/static/css/style.css b/app/static/css/style.css
index 6642b3f..c9a65b9 100644
--- a/app/static/css/style.css
+++ b/app/static/css/style.css
@@ -247,6 +247,37 @@ h2 {
gap: 0.4rem;
}
+/* Sortable table headers */
+table.sortable thead th:not([data-sort-off]) {
+ cursor: pointer;
+ user-select: none;
+}
+
+table.sortable thead th:not([data-sort-off]):hover {
+ color: var(--text);
+}
+
+table.sortable thead th[aria-sort]::after {
+ margin-left: 0.3rem;
+}
+
+table.sortable thead th[aria-sort="ascending"]::after {
+ content: "\25B2";
+}
+
+table.sortable thead th[aria-sort="descending"]::after {
+ content: "\25BC";
+}
+
+.sort-link {
+ color: inherit;
+ text-decoration: none;
+}
+
+.sort-link:hover {
+ text-decoration: underline;
+}
+
/* Tags / definition lists */
.tag {
display: inline-block;
diff --git a/app/static/js/sortable-tables.js b/app/static/js/sortable-tables.js
new file mode 100644
index 0000000..6437988
--- /dev/null
+++ b/app/static/js/sortable-tables.js
@@ -0,0 +1,77 @@
+(function () {
+ "use strict";
+
+ function getCellText(row, index) {
+ var cell = row.children[index];
+ return cell ? cell.textContent.trim() : "";
+ }
+
+ function isEmptyValue(text) {
+ return text === "" || text === "—";
+ }
+
+ function isPureNumber(text) {
+ return /^-?\d+(\.\d+)?$/.test(text);
+ }
+
+ function columnIsNumeric(rows, index) {
+ var sawValue = false;
+ for (var i = 0; i < rows.length; i++) {
+ var text = getCellText(rows[i], index);
+ if (isEmptyValue(text)) continue;
+ sawValue = true;
+ if (!isPureNumber(text)) return false;
+ }
+ return sawValue;
+ }
+
+ function compareRows(a, b, index, numeric, dir) {
+ var textA = getCellText(a, index);
+ var textB = getCellText(b, index);
+ var emptyA = isEmptyValue(textA);
+ var emptyB = isEmptyValue(textB);
+ if (emptyA && emptyB) return 0;
+ if (emptyA) return 1;
+ if (emptyB) return -1;
+
+ var result = numeric
+ ? parseFloat(textA) - parseFloat(textB)
+ : textA.localeCompare(textB, undefined, { numeric: true, sensitivity: "base" });
+ return dir === "desc" ? -result : result;
+ }
+
+ function sortTable(table, th) {
+ var thead = table.tHead;
+ var tbody = table.tBodies[0];
+ if (!thead || !tbody) return;
+
+ var headerRow = thead.rows[0];
+ var index = Array.prototype.indexOf.call(headerRow.children, th);
+ if (index === -1) return;
+
+ var rows = Array.prototype.slice.call(tbody.rows);
+ if (rows.length < 2) return;
+
+ var nextDir = th.getAttribute("data-sort-dir") === "asc" ? "desc" : "asc";
+ Array.prototype.forEach.call(headerRow.children, function (cell) {
+ cell.removeAttribute("data-sort-dir");
+ cell.removeAttribute("aria-sort");
+ });
+ th.setAttribute("data-sort-dir", nextDir);
+ th.setAttribute("aria-sort", nextDir === "asc" ? "ascending" : "descending");
+
+ var numeric = columnIsNumeric(rows, index);
+ rows.sort(function (a, b) {
+ return compareRows(a, b, index, numeric, nextDir);
+ });
+ rows.forEach(function (row) {
+ tbody.appendChild(row);
+ });
+ }
+
+ document.addEventListener("click", function (event) {
+ var th = event.target.closest("table.sortable thead th");
+ if (!th || th.hasAttribute("data-sort-off") || th.textContent.trim() === "") return;
+ sortTable(th.closest("table"), th);
+ });
+})();
diff --git a/app/templates/_scopes_table_card.html b/app/templates/_scopes_table_card.html
index 8b43b32..dc10170 100644
--- a/app/templates/_scopes_table_card.html
+++ b/app/templates/_scopes_table_card.html
@@ -11,7 +11,7 @@
{%- endif -%}
{%- endmacro -%}
-
+
| Scope |
@@ -21,7 +21,7 @@
Magnification |
Objective thread |
{% for ct in column_types %}
- {{ ct.name }} |
+ {{ ct.name }} |
{% endfor %}
diff --git a/app/templates/admin/_attachment_point_names_table.html b/app/templates/admin/_attachment_point_names_table.html
index 3c6fce8..0d567fc 100644
--- a/app/templates/admin/_attachment_point_names_table.html
+++ b/app/templates/admin/_attachment_point_names_table.html
@@ -1,5 +1,5 @@
-
+
| ID |
diff --git a/app/templates/admin/_attachment_point_row.html b/app/templates/admin/_attachment_point_row.html
index e09a8de..c661fe8 100644
--- a/app/templates/admin/_attachment_point_row.html
+++ b/app/templates/admin/_attachment_point_row.html
@@ -20,7 +20,6 @@
{% endif %}
| {{ ap.id }} |
- {{ ap.augmented_part.description | truncate(40) }} |
{{ ap.attachment_point_name.name }} |
{{ conn_type }} |
{{ conn_label }} |
diff --git a/app/templates/admin/_attachment_points_table.html b/app/templates/admin/_attachment_points_table.html
index ae18778..094349c 100644
--- a/app/templates/admin/_attachment_points_table.html
+++ b/app/templates/admin/_attachment_points_table.html
@@ -1,9 +1,8 @@
-
+
| ID |
- Augmented Part |
Point Name |
Connection Type |
Connection |
diff --git a/app/templates/admin/_augmented_parts_table.html b/app/templates/admin/_augmented_parts_table.html
index 034f175..22e4da2 100644
--- a/app/templates/admin/_augmented_parts_table.html
+++ b/app/templates/admin/_augmented_parts_table.html
@@ -10,6 +10,8 @@
hx-target="#augmented-parts-table-card"
hx-trigger="input delay:300ms, change"
hx-swap="outerHTML">
+
+