UI refinement: Back buttons
Quality Gate / gate (push) Failing after 49s

This commit is contained in:
Jim Lancaster
2026-08-24 12:44:02 -05:00
parent f15c9834e4
commit 9a7970c533
12 changed files with 129 additions and 13 deletions
+2
View File
@@ -28,6 +28,7 @@ Documents manages the archival record for each historical artifact independently
- # Sources reflects the count of linked Source rows for each Document.
- Date display prefers exact date, then approximate date, then `Unknown`.
- Selecting a row opens Document Detail.
- Row navigation includes list context so Document Detail provides **Back to Documents**.
- No records displays `No documents found in repository.`
## Create and Edit Behavior
@@ -68,6 +69,7 @@ Rules:
## Detail Behavior
- The heading shows name, type, and internal ID.
- The header includes a contextual back action: **Back to Documents** by default, **Back to Person** when opened from Person Detail, and **Back to Job** when opened from Job Detail.
- The first Source, when present, appears in the dark-room viewer.
- Archival Metadata shows authors, Document Type, tags, Document date (`MM-DD-YYYY` for exact dates), location, and archive identifier. Notes appear in a separate archival-notes block within the same card.
- System Logistics shows created and updated timestamps.
+3 -1
View File
@@ -24,6 +24,7 @@ Jobs manages transcription processing runs. A Job belongs to one Document, links
- Search covers Job ID, document name, and status.
- Status is displayed as a semantic status chip.
- Selecting a row opens Job Detail.
- Global Job-list row navigation includes list context so Job Detail provides **Back to Jobs**.
- No records displays `No job records found in repository.`
## Create Behavior
@@ -43,8 +44,9 @@ Jobs manages transcription processing runs. A Job belongs to one Document, links
## Detail and Lifecycle Behavior
- The heading shows Job ID and a status badge.
- Job Detail includes a contextual back action: **Back to Jobs** by default and **Back to Document** when opened from a Document-filtered Job list.
- Execution Logistics shows provider, model, prompt, retry count, and last update.
- Document Links show a clickable Document Name, Sources count, and a single **View Sources** action using document filtering.
- Document Links show a clickable Document Name (with Job context), Sources count, and a single **View Sources** action using job filtering.
- Queued and processing Jobs show an auto-refresh notice and reload every four seconds.
- Polling stops when the Job becomes terminal or a refresh fails.
- Queued and processing Jobs expose **Cancel**.
+2
View File
@@ -25,6 +25,7 @@ People manages reusable historical-person records. A Person may appear in many D
- # Documents reflects how many linked Documents each Person is connected to.
- Birth and death values independently prefer exact date, then approximate date, then `Unknown`.
- Selecting a row opens Person Detail.
- Row navigation includes list context so Person Detail provides **Back to People**.
- No records displays `No person records found in repository.`
## Create and Edit Behavior
@@ -55,6 +56,7 @@ Rules:
## Detail Behavior
- The header provides **New Document**, **Edit Person**, **Edit Photo(s)**, and **Delete**.
- The header includes a contextual back action: **Back to People** by default, and **Back to Document** when opened from Document Detail.
- **New Document** opens Document creation with this Person requested for author preselection.
- Person Detail shows a single-photo viewer with **Previous/Next** navigation; the page-level **Edit Photo(s)** header action opens photo management.
- Photo management (upload, description edit, set-primary, delete) is intentionally moved to `/people/{person_id}/photos`.
@@ -109,7 +109,7 @@ def render_documents_table(rows: Sequence[DocumentTableRow]) -> None:
],
default_sort_by="name",
search_placeholder="Search documents by title, type, or author...",
on_row_click_id=lambda doc_id: ui.navigate.to(f"/documents/{doc_id}"),
on_row_click_id=lambda doc_id: ui.navigate.to(f"/documents/{doc_id}?from=documents"),
)
# Render document type using a subtle Quasar badge
@@ -57,13 +57,18 @@ def _serialize_rows(rows: Sequence[JobTableRow]) -> list[dict[str, Any]]:
]
def render_jobs_table(rows: Sequence[JobTableRow]) -> None:
def render_jobs_table(rows: Sequence[JobTableRow], *, document_context_id: str | None = None) -> None:
"""Render jobs table with search filtering and custom status chips."""
if not rows:
with archival_card(extra_classes="p-8 text-center"):
render_empty_state("No job records found in repository.")
return
def detail_target(job_id: str) -> str:
if document_context_id is not None:
return f"/jobs/{job_id}?from=document&document_id={document_context_id}"
return f"/jobs/{job_id}?from=jobs"
table = build_table(
rows=_serialize_rows(rows),
columns=[
@@ -117,7 +122,7 @@ def render_jobs_table(rows: Sequence[JobTableRow]) -> None:
default_sort_by="updated_sort",
default_descending=True,
search_placeholder="Search jobs by ID, document, or status...",
on_row_click_id=lambda job_id: ui.navigate.to(f"/jobs/{job_id}"),
on_row_click_id=lambda job_id: ui.navigate.to(detail_target(job_id)),
)
# Render job execution status using themed Quasar chips
@@ -94,7 +94,7 @@ def render_people_table(rows: Sequence[PersonTableRow]) -> None:
],
default_sort_by="name",
search_placeholder="Search people by name, tags, FamilySearch ID, or dates...",
on_row_click_id=lambda person_id: ui.navigate.to(f"/people/{person_id}"),
on_row_click_id=lambda person_id: ui.navigate.to(f"/people/{person_id}?from=people"),
)
# Custom column template adding an archival entity icon next to person's name
+17 -1
View File
@@ -209,6 +209,19 @@ def register_page() -> None: # noqa: PLR0915
document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/documents")
settings = resolve_runtime_settings(request)
back_label = "Back to Documents"
back_target = "/documents"
from_context = request.query_params.get("from")
if from_context == "person":
person_id = parse_uuid(request.query_params.get("person_id"))
if person_id is not None:
back_label = "Back to Person"
back_target = f"/people/{person_id}"
elif from_context == "job":
job_id = parse_uuid(request.query_params.get("job_id"))
if job_id is not None:
back_label = "Back to Job"
back_target = f"/jobs/{job_id}"
parsed_doc_id = parsed_record_id(document_id, noun="Document")
if parsed_doc_id is None:
@@ -229,6 +242,7 @@ def register_page() -> None: # noqa: PLR0915
page_header(document.name, subtitle=f"Type: {type_display} | ID: {document.id}")
with ui.row().classes("items-center gap-2"):
ui.button(back_label, on_click=lambda: ui.navigate.to(back_target), icon="arrow_back").props("flat")
ui.button(
"Print",
on_click=lambda: ui.navigate.to(f"/documents/{document.id}/print"),
@@ -584,7 +598,9 @@ def _render_related_people_card(document: Document) -> None:
for person in grouped[role_label]:
ui.button(
person.full_name,
on_click=lambda _=None, person_id=person.id: ui.navigate.to(f"/people/{person_id}"),
on_click=lambda _=None, person_id=person.id: ui.navigate.to(
f"/people/{person_id}?from=document&document_id={document.id}"
),
icon="person",
).props("flat dense no-caps").classes("self-start text-xs font-semibold ui-link-primary")
+21 -6
View File
@@ -68,6 +68,12 @@ def register_page() -> None: # noqa: PLR0915
"ui-btn-primary"
)
ui.button("Refresh", on_click=lambda: render_table.refresh(), icon="refresh").props("flat")
elif parsed_document_id is not None:
ui.button(
"Back to Document",
on_click=lambda: ui.navigate.to(f"/documents/{parsed_document_id}"),
icon="arrow_back",
).props("flat")
@ui.refreshable
async def render_table() -> None:
@@ -86,7 +92,7 @@ def register_page() -> None: # noqa: PLR0915
)
for job in jobs
]
render_jobs_table(rows)
render_jobs_table(rows, document_context_id=str(parsed_document_id) if parsed_document_id else None)
await render_table()
@@ -241,9 +247,17 @@ def register_page() -> None: # noqa: PLR0915
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="arrow_back").props("flat")
@ui.page("/jobs/{job_id}")
async def job_detail_page(job_id: str, session_factory: SessionFactoryDep) -> None:
async def job_detail_page(job_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
jobs_service = JobService(session_factory=session_factory)
render_navigation_header(current_path="/jobs")
back_label = "Back to Jobs"
back_target = "/jobs"
from_context = request.query_params.get("from")
if from_context == "document":
document_id = parse_uuid(request.query_params.get("document_id"))
if document_id is not None:
back_label = "Back to Document"
back_target = f"/documents/{document_id}"
parsed_job_id = parsed_record_id(job_id, noun="Job")
if parsed_job_id is None:
@@ -262,7 +276,7 @@ def register_page() -> None: # noqa: PLR0915
@ui.refreshable
def render_detail() -> None:
active_job = current_job[0]
_render_job_detail_header(active_job)
_render_job_detail_header(active_job, back_label=back_label, back_target=back_target)
with ui.grid().classes("w-full grid-cols-1 md:grid-cols-2 gap-4"):
_render_job_logistics(active_job)
_render_job_document_links(active_job)
@@ -540,10 +554,11 @@ def _render_upload_section(uploaded_files: list[tuple[str, bytes]]) -> None:
render_upload_list()
def _render_job_detail_header(job: Job) -> None:
def _render_job_detail_header(job: Job, *, back_label: str, back_target: str) -> None:
with section_header_row(classes="justify-between items-center"):
page_header(f"Job Record: {job.id}")
with ui.row().classes("items-center gap-2"):
ui.button(back_label, on_click=lambda: ui.navigate.to(back_target), icon="arrow_back").props("flat")
archival_badge(job.status.value.upper())
if job.status in {JobStatus.QUEUED, JobStatus.PROCESSING}:
@@ -584,7 +599,7 @@ def _render_job_document_links(job: Job) -> None:
ui.label("Document Name:").classes("text-xs font-semibold ui-text-primary")
ui.button(
document_name,
on_click=lambda: ui.navigate.to(f"/documents/{job.document_id}"),
on_click=lambda: ui.navigate.to(f"/documents/{job.document_id}?from=job&job_id={job.id}"),
icon="description",
).props("flat dense no-caps").classes("ui-link-primary text-xs")
@@ -592,7 +607,7 @@ def _render_job_document_links(job: Job) -> None:
with ui.row().classes("w-full gap-2 mt-2"):
ui.button(
"View Sources",
on_click=lambda: ui.navigate.to(f"/sources?document_id={job.document_id}"),
on_click=lambda: ui.navigate.to(f"/sources?job_id={job.id}"),
icon="description",
).props("flat dense text-xs").classes("ui-link-primary")
+11 -1
View File
@@ -31,6 +31,7 @@ from transcription.ui.components.formatters import compact_date
from transcription.ui.components.formatters import family_search_url
from transcription.ui.components.formatters import google_maps_search_url
from transcription.ui.components.formatters import parse_iso_date
from transcription.ui.components.formatters import parse_uuid
from transcription.ui.components.guards import parsed_record_id
from transcription.ui.components.guards import render_record_not_found
from transcription.ui.components.media_urls import resolve_media_url
@@ -178,6 +179,14 @@ def register_page() -> None: # noqa: PLR0915
people_service = PeopleService(session_factory=session_factory)
photos_service = PhotosService(session_factory=session_factory)
render_navigation_header(current_path="/people")
back_label = "Back to People"
back_target = "/people"
from_context = request.query_params.get("from")
if from_context == "document":
document_id = parse_uuid(request.query_params.get("document_id"))
if document_id is not None:
back_label = "Back to Document"
back_target = f"/documents/{document_id}"
parsed_person_id = parsed_record_id(person_id, noun="Person")
if parsed_person_id is None:
@@ -197,6 +206,7 @@ def register_page() -> None: # noqa: PLR0915
page_header(person.full_name, subtitle=f"Person ID: {person.id}")
with ui.row().classes("items-center gap-2"):
ui.button(back_label, on_click=lambda: ui.navigate.to(back_target), icon="arrow_back").props("flat")
ui.button(
"New Document",
on_click=lambda: ui.navigate.to(f"/documents/new?person_id={person.id}"),
@@ -812,7 +822,7 @@ def _render_linked_documents(person: Person) -> None:
},
],
default_sort_by="document_name",
on_row_click_id=lambda doc_id: ui.navigate.to(f"/documents/{doc_id}"),
on_row_click_id=lambda doc_id: ui.navigate.to(f"/documents/{doc_id}?from=person&person_id={person.id}"),
show_search=False,
)
table.add_slot(
+30
View File
@@ -153,6 +153,36 @@ class TestDocumentsPageRendering:
assert "1924-07-04" not in response.text
assert "PIPELINE JOBS" in response.text.upper()
assert "Edit Document" in response.text
assert "Back to Documents" in response.text
@pytest.mark.asyncio
async def test_document_detail_page_renders_person_context_back_button(self, app_client, seed_person_and_document):
_, client = app_client
doc_id, person_id = seed_person_and_document
response = client.get(f"/ui/documents/{doc_id}?from=person&person_id={person_id}")
assert response.status_code == 200
assert "Back to Person" in response.text
@pytest.mark.asyncio
async def test_document_detail_page_renders_job_context_back_button(self, app_client):
_, client = app_client
async with session_scope() as session:
doc = Document(name="Job-linked Document")
session.add(doc)
await session.flush()
job = Job(document_id=doc.id)
session.add(job)
await session.commit()
doc_id = str(doc.id)
job_id = str(job.id)
response = client.get(f"/ui/documents/{doc_id}?from=job&job_id={job_id}")
assert response.status_code == 200
assert "Back to Job" in response.text
@pytest.mark.asyncio
async def test_document_jobs_page_redirects_to_filtered_jobs(self, app_client):
+16
View File
@@ -85,6 +85,7 @@ class TestJobsPageRendering:
assert "Jobs for Document" in response.text
assert "Create job" not in response.text
assert "Refresh" not in response.text
assert "Back to Document" in response.text
assert first_job_id in response.text
assert second_job_id not in response.text
@@ -131,10 +132,25 @@ class TestJobsPageRendering:
assert "Document Name:" in response.text
assert "Sources:" in response.text
assert "View Sources" in response.text
assert "Back to Jobs" in response.text
assert "View Linked Document" not in response.text
assert "View Linked Sources" not in response.text
assert "updates automatically while the job is active" in response.text
@pytest.mark.asyncio
async def test_job_detail_page_renders_document_context_back_button(
self,
app_client,
seed_document_with_unlinked_job,
):
_, client = app_client
document_id, job_id = seed_document_with_unlinked_job
response = client.get(f"/ui/jobs/{job_id}?from=document&document_id={document_id}")
assert response.status_code == 200
assert "Back to Document" in response.text
@pytest.mark.asyncio
async def test_job_cancel_page_renders_confirmation(self, app_client, seed_document_with_unlinked_job):
_, client = app_client
+18
View File
@@ -134,9 +134,27 @@ class TestPeoplePageRendering:
assert "Open in FamilySearch" not in response.text
assert "FamilySearch ID:" in response.text
assert "familysearch.org/tree/person/details/G8T4-MDQ" in response.text
assert "Back to People" in response.text
assert "New Document" in response.text
assert "No linked documents yet." in response.text
@pytest.mark.asyncio
async def test_person_detail_page_renders_document_context_back_button(self, app_client):
_, client = app_client
async with session_scope() as session:
person = Person(given_names="Context", last_name="Person")
document = Document(name="Context Document")
session.add_all([person, document])
await session.commit()
person_id = str(person.id)
document_id = str(document.id)
response = client.get(f"/ui/people/{person_id}?from=document&document_id={document_id}")
assert response.status_code == 200
assert "Back to Document" in response.text
@pytest.mark.asyncio
async def test_person_detail_page_resolves_relative_photo_path(self, app_client):
app, client = app_client