feat: add clickable sort controls to list pages with alpha default for nodes

Add sort/order query parameters to Nodes, Advertisements, and Messages
API endpoints. Nodes default to alpha-by-name (via COALESCE of name tag,
node name, public key). Ads and Messages default to newest-first.

Frontend adds sortableTableHeader() component with asc/desc toggle
indicators. Sort state is preserved in URL params, surviving
auto-refresh and pagination.
This commit is contained in:
Louis King
2026-05-05 17:05:54 +01:00
parent 2e41f182be
commit 160ba6d5bf
12 changed files with 1311 additions and 23 deletions
+187
View File
@@ -292,3 +292,190 @@ class TestListAdvertisementsFilters:
assert response.status_code == 200
data = response.json()
assert len(data["items"]) == 1
class TestAdvertisementSort:
"""Tests for advertisement list sort parameters."""
def test_sort_by_time_default(self, client_no_auth, api_db_session):
"""Default sort is received_at DESC."""
now = datetime.now(timezone.utc)
ad_old = Advertisement(
public_key="aa" * 16,
name="Old",
adv_type="CLIENT",
received_at=now - timedelta(hours=1),
)
ad_new = Advertisement(
public_key="bb" * 16,
name="New",
adv_type="CLIENT",
received_at=now,
)
api_db_session.add_all([ad_old, ad_new])
api_db_session.commit()
response = client_no_auth.get("/api/v1/advertisements")
assert response.status_code == 200
items = response.json()["items"]
assert items[0]["name"] == "New"
assert items[1]["name"] == "Old"
def test_sort_by_time_asc(self, client_no_auth, api_db_session):
"""sort=time&order=asc returns oldest first."""
now = datetime.now(timezone.utc)
ad_old = Advertisement(
public_key="aa" * 16,
name="Old",
adv_type="CLIENT",
received_at=now - timedelta(hours=1),
)
ad_new = Advertisement(
public_key="bb" * 16,
name="New",
adv_type="CLIENT",
received_at=now,
)
api_db_session.add_all([ad_old, ad_new])
api_db_session.commit()
response = client_no_auth.get("/api/v1/advertisements?sort=time&order=asc")
assert response.status_code == 200
items = response.json()["items"]
assert items[0]["name"] == "Old"
assert items[1]["name"] == "New"
def test_sort_by_node_name(self, client_no_auth, api_db_session):
"""sort=node_name sorts by display name (COALESCE)."""
from meshcore_hub.common.models import Node
now = datetime.now(timezone.utc)
node_b = Node(
public_key="aa" * 16,
name="Bravo",
first_seen=now,
)
node_a = Node(
public_key="bb" * 16,
name="Alpha",
first_seen=now,
)
api_db_session.add_all([node_b, node_a])
api_db_session.commit()
ad_b = Advertisement(
public_key="aa" * 16,
name="AdBravo",
adv_type="CLIENT",
received_at=now,
node_id=node_b.id,
)
ad_a = Advertisement(
public_key="bb" * 16,
name="AdAlpha",
adv_type="CLIENT",
received_at=now,
node_id=node_a.id,
)
api_db_session.add_all([ad_b, ad_a])
api_db_session.commit()
response = client_no_auth.get("/api/v1/advertisements?sort=node_name&order=asc")
assert response.status_code == 200
items = response.json()["items"]
assert items[0]["node_name"] == "Alpha"
assert items[1]["node_name"] == "Bravo"
def test_sort_by_node_name_tag_priority(self, client_no_auth, api_db_session):
"""Name tag takes priority over SourceNode.name in sort."""
from meshcore_hub.common.models import Node, NodeTag
now = datetime.now(timezone.utc)
node_b = Node(
public_key="aa" * 16,
name="Alpha",
first_seen=now,
)
node_a = Node(
public_key="bb" * 16,
name="Bravo",
first_seen=now,
)
api_db_session.add_all([node_b, node_a])
api_db_session.commit()
tag_b = NodeTag(node_id=node_b.id, key="name", value="Zebra")
tag_a = NodeTag(node_id=node_a.id, key="name", value="Aardvark")
api_db_session.add_all([tag_b, tag_a])
api_db_session.commit()
ad_b = Advertisement(
public_key="aa" * 16,
name="AdB",
adv_type="CLIENT",
received_at=now,
node_id=node_b.id,
)
ad_a = Advertisement(
public_key="bb" * 16,
name="AdA",
adv_type="CLIENT",
received_at=now,
node_id=node_a.id,
)
api_db_session.add_all([ad_b, ad_a])
api_db_session.commit()
response = client_no_auth.get("/api/v1/advertisements?sort=node_name&order=asc")
assert response.status_code == 200
items = response.json()["items"]
assert items[0]["node_name"] == "Bravo"
assert items[1]["node_name"] == "Alpha"
def test_sort_by_public_key(self, client_no_auth, api_db_session):
"""sort=public_key orders by public_key."""
now = datetime.now(timezone.utc)
ad_b = Advertisement(
public_key="bb" * 16,
name="Bravo",
adv_type="CLIENT",
received_at=now,
)
ad_a = Advertisement(
public_key="aa" * 16,
name="Alpha",
adv_type="CLIENT",
received_at=now,
)
api_db_session.add_all([ad_b, ad_a])
api_db_session.commit()
response = client_no_auth.get(
"/api/v1/advertisements?sort=public_key&order=asc"
)
assert response.status_code == 200
items = response.json()["items"]
assert items[0]["public_key"] == "aa" * 16
def test_sort_invalid_ignored(self, client_no_auth, api_db_session):
"""Invalid sort value falls back to default (time desc)."""
now = datetime.now(timezone.utc)
ad_old = Advertisement(
public_key="aa" * 16,
name="Old",
adv_type="CLIENT",
received_at=now - timedelta(hours=1),
)
ad_new = Advertisement(
public_key="bb" * 16,
name="New",
adv_type="CLIENT",
received_at=now,
)
api_db_session.add_all([ad_old, ad_new])
api_db_session.commit()
response = client_no_auth.get("/api/v1/advertisements?sort=invalid_column")
assert response.status_code == 200
items = response.json()["items"]
assert items[0]["name"] == "New"
+118
View File
@@ -347,3 +347,121 @@ class TestListMessagesFilters:
assert response.status_code == 200
data = response.json()
assert len(data["items"]) == 0
class TestMessageSort:
"""Tests for message list sort parameters."""
def test_sort_by_time_default(self, client_no_auth, api_db_session):
"""Default sort is received_at DESC."""
now = datetime.now(timezone.utc)
msg_old = Message(
message_type="direct",
pubkey_prefix="aa",
text="Old msg",
received_at=now - timedelta(hours=1),
)
msg_new = Message(
message_type="direct",
pubkey_prefix="bb",
text="New msg",
received_at=now,
)
api_db_session.add_all([msg_old, msg_new])
api_db_session.commit()
response = client_no_auth.get("/api/v1/messages")
assert response.status_code == 200
items = response.json()["items"]
assert items[0]["text"] == "New msg"
assert items[1]["text"] == "Old msg"
def test_sort_by_type(self, client_no_auth, api_db_session):
"""sort=type&order=asc sorts by message_type."""
now = datetime.now(timezone.utc)
msg_ch = Message(
message_type="channel",
channel_idx=1,
text="Channel msg",
received_at=now,
)
msg_ct = Message(
message_type="contact",
text="Contact msg",
received_at=now,
)
api_db_session.add_all([msg_ch, msg_ct])
api_db_session.commit()
response = client_no_auth.get("/api/v1/messages?sort=type&order=asc")
assert response.status_code == 200
items = response.json()["items"]
assert items[0]["message_type"] == "channel"
assert items[1]["message_type"] == "contact"
def test_sort_by_from(self, client_no_auth, api_db_session):
"""sort=from&order=asc sorts by pubkey_prefix."""
now = datetime.now(timezone.utc)
msg_b = Message(
message_type="direct",
pubkey_prefix="bb_prefix",
text="From B",
received_at=now,
)
msg_a = Message(
message_type="direct",
pubkey_prefix="aa_prefix",
text="From A",
received_at=now,
)
api_db_session.add_all([msg_b, msg_a])
api_db_session.commit()
response = client_no_auth.get("/api/v1/messages?sort=from&order=asc")
assert response.status_code == 200
items = response.json()["items"]
assert items[0]["text"] == "From A"
assert items[1]["text"] == "From B"
def test_sort_by_message(self, client_no_auth, api_db_session):
"""sort=message&order=asc sorts by text."""
now = datetime.now(timezone.utc)
msg_b = Message(
message_type="direct",
text="Zebra message",
received_at=now,
)
msg_a = Message(
message_type="direct",
text="Alpha message",
received_at=now,
)
api_db_session.add_all([msg_b, msg_a])
api_db_session.commit()
response = client_no_auth.get("/api/v1/messages?sort=message&order=asc")
assert response.status_code == 200
items = response.json()["items"]
assert items[0]["text"] == "Alpha message"
assert items[1]["text"] == "Zebra message"
def test_sort_invalid_ignored(self, client_no_auth, api_db_session):
"""Invalid sort value falls back to default (time desc)."""
now = datetime.now(timezone.utc)
msg_old = Message(
message_type="direct",
text="Old",
received_at=now - timedelta(hours=1),
)
msg_new = Message(
message_type="direct",
text="New",
received_at=now,
)
api_db_session.add_all([msg_old, msg_new])
api_db_session.commit()
response = client_no_auth.get("/api/v1/messages?sort=invalid_column")
assert response.status_code == 200
items = response.json()["items"]
assert items[0]["text"] == "New"
+228
View File
@@ -466,6 +466,234 @@ class TestNodeTags:
assert data["value"] == "admin-val"
class TestNodeSort:
"""Tests for node list sort parameters."""
def test_sort_by_name_default(self, client_no_auth, api_db_session):
"""Default sort (no params) returns nodes alpha by display name."""
from datetime import datetime, timezone
from meshcore_hub.common.models import Node
node_b = Node(
public_key="bb" * 32,
name="Bravo",
adv_type="CLIENT",
first_seen=datetime.now(timezone.utc),
last_seen=datetime.now(timezone.utc),
)
node_a = Node(
public_key="aa" * 32,
name="Alpha",
adv_type="CLIENT",
first_seen=datetime.now(timezone.utc),
last_seen=datetime.now(timezone.utc),
)
api_db_session.add_all([node_b, node_a])
api_db_session.commit()
response = client_no_auth.get("/api/v1/nodes")
assert response.status_code == 200
items = response.json()["items"]
assert len(items) == 2
assert items[0]["name"] == "Alpha"
assert items[1]["name"] == "Bravo"
def test_sort_by_name_asc(self, client_no_auth, api_db_session):
"""Explicit sort=name&order=asc."""
from datetime import datetime, timezone
from meshcore_hub.common.models import Node
node_b = Node(
public_key="bb" * 32,
name="Bravo",
adv_type="CLIENT",
first_seen=datetime.now(timezone.utc),
)
node_a = Node(
public_key="aa" * 32,
name="Alpha",
adv_type="CLIENT",
first_seen=datetime.now(timezone.utc),
)
api_db_session.add_all([node_b, node_a])
api_db_session.commit()
response = client_no_auth.get("/api/v1/nodes?sort=name&order=asc")
assert response.status_code == 200
items = response.json()["items"]
assert items[0]["name"] == "Alpha"
def test_sort_by_name_desc(self, client_no_auth, api_db_session):
"""sort=name&order=desc returns Z-to-A."""
from datetime import datetime, timezone
from meshcore_hub.common.models import Node
node_a = Node(
public_key="aa" * 32,
name="Alpha",
adv_type="CLIENT",
first_seen=datetime.now(timezone.utc),
)
node_b = Node(
public_key="bb" * 32,
name="Bravo",
adv_type="CLIENT",
first_seen=datetime.now(timezone.utc),
)
api_db_session.add_all([node_a, node_b])
api_db_session.commit()
response = client_no_auth.get("/api/v1/nodes?sort=name&order=desc")
assert response.status_code == 200
items = response.json()["items"]
assert items[0]["name"] == "Bravo"
assert items[1]["name"] == "Alpha"
def test_sort_by_public_key(self, client_no_auth, api_db_session):
"""sort=public_key orders by public_key."""
from datetime import datetime, timezone
from meshcore_hub.common.models import Node
node_b = Node(
public_key="bb" * 32,
name="Alpha",
adv_type="CLIENT",
first_seen=datetime.now(timezone.utc),
)
node_a = Node(
public_key="aa" * 32,
name="Bravo",
adv_type="CLIENT",
first_seen=datetime.now(timezone.utc),
)
api_db_session.add_all([node_b, node_a])
api_db_session.commit()
response = client_no_auth.get("/api/v1/nodes?sort=public_key&order=asc")
assert response.status_code == 200
items = response.json()["items"]
assert items[0]["public_key"] == "aa" * 32
def test_sort_by_last_seen(self, client_no_auth, api_db_session):
"""sort=last_seen&order=asc returns oldest first."""
from datetime import datetime, timedelta, timezone
from meshcore_hub.common.models import Node
now = datetime.now(timezone.utc)
node_old = Node(
public_key="aa" * 32,
name="Old",
adv_type="CLIENT",
first_seen=now - timedelta(days=2),
last_seen=now - timedelta(days=1),
)
node_new = Node(
public_key="bb" * 32,
name="New",
adv_type="CLIENT",
first_seen=now,
last_seen=now,
)
api_db_session.add_all([node_old, node_new])
api_db_session.commit()
response = client_no_auth.get("/api/v1/nodes?sort=last_seen&order=asc")
assert response.status_code == 200
items = response.json()["items"]
assert items[0]["name"] == "Old"
assert items[1]["name"] == "New"
def test_sort_name_tag_priority(self, client_no_auth, api_db_session):
"""Name tag takes priority over node.name in sort."""
from datetime import datetime, timezone
from meshcore_hub.common.models import Node, NodeTag
node_b = Node(
public_key="bb" * 32,
name="Alpha",
adv_type="CLIENT",
first_seen=datetime.now(timezone.utc),
)
node_a = Node(
public_key="aa" * 32,
name="Bravo",
adv_type="CLIENT",
first_seen=datetime.now(timezone.utc),
)
api_db_session.add_all([node_b, node_a])
api_db_session.commit()
tag_b = NodeTag(node_id=node_b.id, key="name", value="Zebra")
tag_a = NodeTag(node_id=node_a.id, key="name", value="Aardvark")
api_db_session.add_all([tag_b, tag_a])
api_db_session.commit()
response = client_no_auth.get("/api/v1/nodes?sort=name&order=asc")
assert response.status_code == 200
items = response.json()["items"]
assert items[0]["name"] == "Bravo"
assert items[1]["name"] == "Alpha"
def test_sort_invalid_ignored(self, client_no_auth, api_db_session):
"""Invalid sort value falls back to default (name alpha)."""
from datetime import datetime, timezone
from meshcore_hub.common.models import Node
node_b = Node(
public_key="bb" * 32,
name="Bravo",
adv_type="CLIENT",
first_seen=datetime.now(timezone.utc),
)
node_a = Node(
public_key="aa" * 32,
name="Alpha",
adv_type="CLIENT",
first_seen=datetime.now(timezone.utc),
)
api_db_session.add_all([node_b, node_a])
api_db_session.commit()
response = client_no_auth.get("/api/v1/nodes?sort=invalid_column")
assert response.status_code == 200
items = response.json()["items"]
assert items[0]["name"] == "Alpha"
def test_sort_nodes_with_null_name(self, client_no_auth, api_db_session):
"""Nodes with name=NULL sort by public_key via COALESCE fallback."""
from datetime import datetime, timezone
from meshcore_hub.common.models import Node
node_no_name = Node(
public_key="bb" * 32,
name=None,
adv_type="CLIENT",
first_seen=datetime.now(timezone.utc),
)
node_named = Node(
public_key="aa" * 32,
name="Alpha",
adv_type="CLIENT",
first_seen=datetime.now(timezone.utc),
)
api_db_session.add_all([node_no_name, node_named])
api_db_session.commit()
response = client_no_auth.get("/api/v1/nodes?sort=name&order=asc")
assert response.status_code == 200
items = response.json()["items"]
assert items[0]["name"] == "Alpha"
assert items[1]["name"] is None
class TestTagValidation:
"""Unit tests for validate_and_coerce_tag_value."""