From b1570aba633875051cfdcacc9766de845c5017e3 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 24 Jul 2026 08:56:36 +0900 Subject: [PATCH] web: add sortable ticket table --- crates/workspace-server/src/server.rs | 7 +- .../console/worker-console.ui.test.ts | 8 +- .../w/[workspaceId]/tickets/+page.svelte | 331 ++++++++++++++++-- .../routes/w/[workspaceId]/tickets/+page.ts | 2 +- 4 files changed, 318 insertions(+), 30 deletions(-) diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 67479d86..21eacad2 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -1395,9 +1395,10 @@ async fn scoped_delete_profile_source( async fn scoped_list_tickets( State(api): State, AxumPath(path): AxumPath, + Query(query): Query, ) -> ApiResult>> { validate_workspace_scope(&api, &path.workspace_id)?; - list_tickets(State(api)).await + list_tickets(State(api), Query(query)).await } async fn scoped_get_ticket( @@ -3406,8 +3407,10 @@ fn companion_console_extension_point(status: &CompanionStatusResponse) -> Extens async fn list_tickets( State(api): State, + Query(query): Query, ) -> ApiResult>> { - let limit = api.config.max_records.min(200); + let requested_limit = query.limit.unwrap_or(api.config.max_records); + let limit = requested_limit.min(1000); let ProjectRecordList { items, invalid_records, diff --git a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts index 1eb10522..4437ed33 100644 --- a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts +++ b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts @@ -90,10 +90,12 @@ Deno.test("workspace Tickets surface uses read-only Backend Ticket APIs", async "Tickets sidebar section should link to the workspace Tickets surface", ); assert( - ticketsLoad.includes('workspaceApiPath(params.workspaceId, "/tickets")') && - ticketsPage.includes("This surface is read-only") && + ticketsLoad.includes('`${workspaceApiPath(params.workspaceId, "/tickets")}?limit=1000`') && + ticketsPage.includes("Notion-style filtering and sorting") && + ticketsPage.includes("toggleSort('updated_at')") && + ticketsPage.includes("bind:value={stateFilter}") && ticketsPage.includes("workspaceRoute(data.workspaceId, `/tickets/${ticket.id}`)"), - "Tickets list should read the workspace-scoped Ticket API and link to Ticket details", + "Tickets list should read the workspace-scoped Ticket API and expose sortable/filterable table links", ); assert( ticketDetailLoad.includes("`/tickets/${encodeURIComponent(ticketId)}`") && diff --git a/web/workspace/src/routes/w/[workspaceId]/tickets/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/tickets/+page.svelte index c1f27b78..304804fd 100644 --- a/web/workspace/src/routes/w/[workspaceId]/tickets/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/tickets/+page.svelte @@ -1,8 +1,116 @@ @@ -10,47 +118,101 @@ -
+

Workspace records

Tickets

{#if data.tickets.data} - {data.tickets.data.items.length} ticket{data.tickets.data.items.length === 1 ? '' : 's'} + {visibleTickets.length} / {data.tickets.data.items.length} ticket{data.tickets.data.items.length === 1 ? '' : 's'} {/if}

- Tickets are read from the typed Ticket backend. This surface is read-only; creation and queue operations remain outside the browser UI for now. + Tickets are read from the typed Ticket backend. This read-only table supports Notion-style filtering and sorting for browsing imported workspace Tickets.

{#if data.tickets.data} {#if data.tickets.data.items.length === 0}

No Ticket records are present.

{:else} -
- {#each data.tickets.data.items as ticket (ticket.id)} - -
-
- {ticket.title} - {ticket.state} -
-

- {ticket.priority ? `${ticket.priority} priority` : 'priority unspecified'} · {ticket.record_source ?? 'ticket backend'} -

-
-
- Updated {ticket.updated_at ? formatDate(ticket.updated_at) : 'unknown'} - {#if ticket.queued_at} - Queued {formatDate(ticket.queued_at)} - {/if} - {ticket.id} -
-
- {/each} +
+ + + + +
+ +
+ + + + + + + + + + + + + {#each visibleTickets as ticket (ticket.id)} + + + + + + + + + {/each} + +
+ {ticket.title} + {ticket.record_source ?? 'ticket backend'} + {ticket.state}{ticket.priority || '—'}{ticket.updated_at ? formatDate(ticket.updated_at) : 'unknown'} + {#if ticket.queued_at} + {formatDate(ticket.queued_at)} + {#if ticket.queued_by} + {ticket.queued_by} + {/if} + {:else} + + {/if} + {ticket.id}
+
+ + {#if visibleTickets.length === 0} +

No tickets match the current filters.

+ {/if} {/if} {#if data.tickets.data.invalid_records.length > 0} @@ -62,3 +224,124 @@

Waiting for /api/w/{data.workspaceId}/tickets

{/if}
+ + diff --git a/web/workspace/src/routes/w/[workspaceId]/tickets/+page.ts b/web/workspace/src/routes/w/[workspaceId]/tickets/+page.ts index c8518614..89c16f98 100644 --- a/web/workspace/src/routes/w/[workspaceId]/tickets/+page.ts +++ b/web/workspace/src/routes/w/[workspaceId]/tickets/+page.ts @@ -5,7 +5,7 @@ import type { PageLoad } from "./$types"; export const load = (async ({ fetch, params }) => { const tickets = await loadJson( fetch, - workspaceApiPath(params.workspaceId, "/tickets"), + `${workspaceApiPath(params.workspaceId, "/tickets")}?limit=1000`, ); return {