From 57c1d22bb918d3e8c83f264ca367c140c054fe36 Mon Sep 17 00:00:00 2001 From: John Lancaster <32917998+jsl12@users.noreply.github.com> Date: Sun, 28 Jun 2026 14:36:14 -0500 Subject: [PATCH] jobs table --- src/transcription/ui/components/job_table.py | 55 -------------- .../ui/components/table/__init__.py | 4 + .../ui/components/table/common.py | 68 +++++++++++++++++ src/transcription/ui/components/table/jobs.py | 75 +++++++++++++++++++ src/transcription/ui/pages/jobs_page.py | 21 ++++-- 5 files changed, 162 insertions(+), 61 deletions(-) delete mode 100644 src/transcription/ui/components/job_table.py create mode 100644 src/transcription/ui/components/table/__init__.py create mode 100644 src/transcription/ui/components/table/common.py create mode 100644 src/transcription/ui/components/table/jobs.py diff --git a/src/transcription/ui/components/job_table.py b/src/transcription/ui/components/job_table.py deleted file mode 100644 index e14bc38..0000000 --- a/src/transcription/ui/components/job_table.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Reusable jobs table rendering helpers.""" - -from __future__ import annotations - -from collections.abc import Sequence -from dataclasses import dataclass -from uuid import UUID - -from nicegui import ui - - -@dataclass(frozen=True) -class JobTableRow: - """Read model consumed by the shared jobs table component.""" - - id: UUID - status: str - created_at: str - updated_at: str - - -def _serialize_rows(rows: Sequence[JobTableRow]) -> list[dict[str, str]]: - """Convert typed rows into table-compatible dictionaries.""" - return [ - { - "id": str(row.id), - "status": row.status, - "created_at": row.created_at, - "updated_at": row.updated_at, - } - for row in rows - ] - - -def render_jobs_table(rows: Sequence[JobTableRow]) -> None: - """Render jobs table and per-row detail links.""" - if not rows: - ui.label("No jobs yet.") - return - - serialized_rows = _serialize_rows(rows) - ui.table( - columns=[ - {"name": "id", "label": "Job ID", "field": "id"}, - {"name": "status", "label": "Status", "field": "status"}, - {"name": "created_at", "label": "Created", "field": "created_at"}, - {"name": "updated_at", "label": "Updated", "field": "updated_at"}, - ], - rows=serialized_rows, - row_key="id", - ).classes("w-full") - - with ui.column().classes("gap-1"): - for row in serialized_rows: - ui.link(f"Open {row['id']}", f"/jobs/{row['id']}") diff --git a/src/transcription/ui/components/table/__init__.py b/src/transcription/ui/components/table/__init__.py new file mode 100644 index 0000000..1d27ae1 --- /dev/null +++ b/src/transcription/ui/components/table/__init__.py @@ -0,0 +1,4 @@ +from .jobs import JobTableRow +from .jobs import render_jobs_table + +__all__ = ["JobTableRow", "render_jobs_table"] diff --git a/src/transcription/ui/components/table/common.py b/src/transcription/ui/components/table/common.py new file mode 100644 index 0000000..556c206 --- /dev/null +++ b/src/transcription/ui/components/table/common.py @@ -0,0 +1,68 @@ +"""Common logic for generating table widgets.""" + +from collections.abc import Callable +from typing import Any + +from nicegui import events +from nicegui import ui + + +def _extract_row_id(args: Any) -> str | None: + if isinstance(args, dict): + if isinstance(args.get("row"), dict): + row_id = args["row"].get("id") + return str(row_id) if row_id is not None else None + row_id = args.get("id") + return str(row_id) if row_id is not None else None + + if isinstance(args, list): + for value in args: + if isinstance(value, dict): + row_id = value.get("id") + if row_id is not None: + return str(row_id) + + return None + + +def _bind_row_click_handler( + table: Any, + *, + on_row_click_id: Callable[[str], None], +) -> None: + def handle_row_click(event: events.GenericEventArguments) -> None: + row_id = _extract_row_id(event.args) + if row_id is None: + return + on_row_click_id(row_id) + + table.on("rowClick", handle_row_click) + + +def build_table( + rows: list[dict[str, Any]], + columns: list[dict[str, Any]], + *, + default_sort_by: str | None = None, + default_descending: bool = False, + classes: str = "app-table", + on_row_click_id: Callable[[str], None] | None = None, +) -> Any: + pagination: dict[str, Any] = {"rowsPerPage": 25} + if default_sort_by is not None: + pagination["sortBy"] = default_sort_by + pagination["descending"] = default_descending + + table = ( + ui.table( + rows=rows, + columns=columns, + row_key="id", + pagination=pagination, + ) + .classes(classes) + .props('table-style="table-layout: fixed; width: 100%;"') + ) + if on_row_click_id is not None: + _bind_row_click_handler(table, on_row_click_id=on_row_click_id) + return table diff --git a/src/transcription/ui/components/table/jobs.py b/src/transcription/ui/components/table/jobs.py new file mode 100644 index 0000000..a52d786 --- /dev/null +++ b/src/transcription/ui/components/table/jobs.py @@ -0,0 +1,75 @@ +"""Jobs table rendering helpers.""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from datetime import UTC +from datetime import datetime +from typing import Any +from uuid import UUID + +from nicegui import ui + +from .common import build_table + + +@dataclass(frozen=True, slots=True) +class JobTableRow: + """Read model consumed by the jobs table component.""" + + id: UUID + status: str + filename: str + retry_count: int + created_at: str + updated_at: str + + +def _format_timestamp(value: str) -> str: + """Return a friendly UTC timestamp for table display.""" + try: + parsed = datetime.fromisoformat(value) + except ValueError: + return value + parsed = parsed.replace(tzinfo=UTC) if parsed.tzinfo is None else parsed.astimezone(UTC) + return parsed.astimezone().strftime("%b %d, %I:%M %p") + + +def _serialize_rows(rows: Sequence[JobTableRow]) -> list[dict[str, Any]]: + return [ + { + "id": str(row.id), + "status": row.status, + "filename": row.filename, + "retry_count": row.retry_count, + "created_at": _format_timestamp(row.created_at), + "updated_at": _format_timestamp(row.updated_at), + "created_sort": row.created_at, + "updated_sort": row.updated_at, + } + for row in rows + ] + + +def render_jobs_table(rows: Sequence[JobTableRow]) -> None: + """Render jobs table and open a detail page when clicking a row.""" + if not rows: + ui.label("No jobs yet.") + return + + build_table( + rows=_serialize_rows(rows), + columns=[ + {"name": "id", "label": "Job ID", "field": "id", "sortable": True}, + {"name": "status", "label": "Status", "field": "status", "sortable": True}, + {"name": "filename", "label": "Filename", "field": "filename", "sortable": True}, + {"name": "retry_count", "label": "Retries", "field": "retry_count", "sortable": True}, + {"name": "created_at", "label": "Created", "field": "created_at", "sortable": True}, + {"name": "updated_at", "label": "Updated", "field": "updated_at", "sortable": True}, + ], + default_sort_by="created_sort", + default_descending=True, + classes="app-table w-full", + on_row_click_id=lambda job_id: ui.navigate.to(f"/jobs/{job_id}"), + ) diff --git a/src/transcription/ui/pages/jobs_page.py b/src/transcription/ui/pages/jobs_page.py index 9bf4a1e..8077a3c 100644 --- a/src/transcription/ui/pages/jobs_page.py +++ b/src/transcription/ui/pages/jobs_page.py @@ -5,6 +5,7 @@ from __future__ import annotations from uuid import UUID from nicegui import ui +from sqlalchemy.orm import selectinload from sqlmodel import desc from sqlmodel import select @@ -15,18 +16,26 @@ from transcription.models import Transcript from transcription.ui.components.error_presenter import show_error from transcription.ui.components.error_presenter import summarize_error from transcription.ui.components.job_detail import render_job_detail -from transcription.ui.components.job_table import JobTableRow -from transcription.ui.components.job_table import render_jobs_table +from transcription.ui.components.table.jobs import JobTableRow +from transcription.ui.components.table.jobs import render_jobs_table -async def fetch_jobs() -> list[JobTableRow]: +async def fetch_job_rows() -> list[JobTableRow]: """Return jobs for display in most-recent-first order.""" async with get_session() as session: - jobs = (await session.exec(select(Job).order_by(desc(Job.created_at)))).all() + jobs = ( + await session.exec( + select(Job) + .options(selectinload(Job.document)) # pyright: ignore[reportArgumentType] + .order_by(desc(Job.created_at)) + ) + ).all() return [ JobTableRow( id=job.id, status=job.status.value, + filename=job.filename, + retry_count=job.retry_count, created_at=job.created_at.isoformat(), updated_at=job.updated_at.isoformat(), ) @@ -55,7 +64,7 @@ def register_page() -> None: @ui.refreshable async def render_table() -> None: - jobs = await fetch_jobs() + jobs = await fetch_job_rows() render_jobs_table(jobs) async def refresh() -> None: @@ -69,7 +78,7 @@ def register_page() -> None: ui.button("Refresh", on_click=refresh) await render_table() - ui.link("Back to upload", "/") + ui.link("Back to upload", "/upload") @ui.page("/jobs/{job_id}") async def job_detail_page(job_id: str) -> None: