feat(web): adopt TanStack Query for SPA data layer; consolidate shared UI

Migrate the React SPA off the bespoke useApiFetch hook and raw useEffect fetches to TanStack Query: useQuery/useQueries for reads, useMutation + invalidateQueries for writes; a central query-key factory and invalidation helpers mirroring the backend cache_invalidation prefixes (channels/routes/nodes/messages/profiles/dashboard/adoptions); polling via refetchInterval (useAutoRefresh now returns pause state only); RouteCard self-fetches its detail/history, dropping the hand-rolled caches. Adds QueryClientProvider in App, a renderWithProviders test helper, and deletes useApiFetch.

Also consolidates recurring UI into reusable components (Breadcrumbs, ListToolbar, Modal/ConfirmDialog, NotFoundState, TimeAgo, Definition, CopyableValue, MeshQrCode, Badges, SectionGroup, PageHeader) and fixes the FilterForm clear button, merged toggle wrappers, and role-aware channels/messages cache keys so client invalidation matches the server.

Verified: tsc --noEmit clean, vitest 113 passed, pytest test_web 251 + test_cache 119 passed, pre-commit --all-files green.
This commit is contained in:
Louis King
2026-07-21 23:24:58 +01:00
parent bf8e0620a5
commit de9784211d
63 changed files with 2703 additions and 1616 deletions
+44 -2
View File
@@ -1313,12 +1313,13 @@ class TestKeyBuilders:
):
scope = {
"type": "http",
"path": "/api/v1/channels",
"query_string": b"",
"headers": [],
}
request = Request(scope)
key = _channels_key_builder(request)
assert key == "channels:role=operator:"
assert key == "/api/v1/channels:role=operator:"
def test_channels_key_builder_anonymous(self):
from meshcore_hub.api.routes.channels import _channels_key_builder
@@ -1329,6 +1330,7 @@ class TestKeyBuilders:
):
scope = {
"type": "http",
"path": "/api/v1/channels",
"query_string": b"",
"headers": [],
}
@@ -1345,14 +1347,54 @@ class TestKeyBuilders:
):
scope = {
"type": "http",
"path": "/api/v1/messages",
"query_string": b"limit=10&offset=0",
"headers": [],
}
request = Request(scope)
key = _messages_key_builder(request)
assert "role=admin" in key
assert key.startswith("/api/v1/messages:role=admin:")
assert "limit=10" in key
def test_role_aware_keys_match_invalidation_prefixes(self):
"""The GET cache key must live under the prefix the matching
invalidation helper drops, otherwise a mutation never clears the
stale cached list (the store path and delete path must agree).
"""
from meshcore_hub.api.routes.channels import _channels_key_builder
from meshcore_hub.api.routes.messages import _messages_key_builder
with (
patch(
"meshcore_hub.api.routes.channels.resolve_user_role",
return_value="admin",
),
patch(
"meshcore_hub.api.routes.messages.resolve_user_role",
return_value="admin",
),
):
channels_req = Request(
{
"type": "http",
"path": "/api/v1/channels",
"query_string": b"",
"headers": [],
}
)
messages_req = Request(
{
"type": "http",
"path": "/api/v1/messages",
"query_string": b"",
"headers": [],
}
)
# invalidate_channels drops "/api/v1/channels",
# invalidate_messages drops "/api/v1/messages".
assert _channels_key_builder(channels_req).startswith("/api/v1/channels")
assert _messages_key_builder(messages_req).startswith("/api/v1/messages")
def _make_request_with_cache(cache):
"""Build a Request whose ``app.state.redis_cache`` is *cache* (or absent)."""