diff --git a/docs/ui/pages/documents.md b/docs/ui/pages/documents.md index 92cc92c..2a4361b 100644 --- a/docs/ui/pages/documents.md +++ b/docs/ui/pages/documents.md @@ -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. diff --git a/docs/ui/pages/jobs.md b/docs/ui/pages/jobs.md index eded8a2..1d751f8 100644 --- a/docs/ui/pages/jobs.md +++ b/docs/ui/pages/jobs.md @@ -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**. diff --git a/docs/ui/pages/people.md b/docs/ui/pages/people.md index 7b335f6..456aa96 100644 --- a/docs/ui/pages/people.md +++ b/docs/ui/pages/people.md @@ -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`. diff --git a/src/transcription/ui/components/table/documents.py b/src/transcription/ui/components/table/documents.py index 9e8fdc7..660aa57 100644 --- a/src/transcription/ui/components/table/documents.py +++ b/src/transcription/ui/components/table/documents.py @@ -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 diff --git a/src/transcription/ui/components/table/jobs.py b/src/transcription/ui/components/table/jobs.py index fa12840..7904f42 100644 --- a/src/transcription/ui/components/table/jobs.py +++ b/src/transcription/ui/components/table/jobs.py @@ -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 diff --git a/src/transcription/ui/components/table/people.py b/src/transcription/ui/components/table/people.py index 49c58b4..9149731 100644 --- a/src/transcription/ui/components/table/people.py +++ b/src/transcription/ui/components/table/people.py @@ -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 diff --git a/src/transcription/ui/pages/documents_page.py b/src/transcription/ui/pages/documents_page.py index e4d978c..82fda73 100644 --- a/src/transcription/ui/pages/documents_page.py +++ b/src/transcription/ui/pages/documents_page.py @@ -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") diff --git a/src/transcription/ui/pages/jobs_page.py b/src/transcription/ui/pages/jobs_page.py index 5daf13d..e9a4d89 100644 --- a/src/transcription/ui/pages/jobs_page.py +++ b/src/transcription/ui/pages/jobs_page.py @@ -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") diff --git a/src/transcription/ui/pages/people_page.py b/src/transcription/ui/pages/people_page.py index 625a191..0fc69f8 100644 --- a/src/transcription/ui/pages/people_page.py +++ b/src/transcription/ui/pages/people_page.py @@ -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( diff --git a/tests/ui/test_documents_page.py b/tests/ui/test_documents_page.py index bda87ad..d4a2820 100644 --- a/tests/ui/test_documents_page.py +++ b/tests/ui/test_documents_page.py @@ -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): diff --git a/tests/ui/test_jobs_page.py b/tests/ui/test_jobs_page.py index 4331c75..3e1f79f 100644 --- a/tests/ui/test_jobs_page.py +++ b/tests/ui/test_jobs_page.py @@ -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 diff --git a/tests/ui/test_people_page.py b/tests/ui/test_people_page.py index 679848a..ee24aaf 100644 --- a/tests/ui/test_people_page.py +++ b/tests/ui/test_people_page.py @@ -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