web: add sortable ticket table
This commit is contained in:
parent
4ff599c011
commit
b1570aba63
|
|
@ -1395,9 +1395,10 @@ async fn scoped_delete_profile_source(
|
||||||
async fn scoped_list_tickets(
|
async fn scoped_list_tickets(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||||
|
Query(query): Query<TicketKanbanQuery>,
|
||||||
) -> ApiResult<Json<ListResponse<crate::records::TicketSummary>>> {
|
) -> ApiResult<Json<ListResponse<crate::records::TicketSummary>>> {
|
||||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
list_tickets(State(api)).await
|
list_tickets(State(api), Query(query)).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn scoped_get_ticket(
|
async fn scoped_get_ticket(
|
||||||
|
|
@ -3406,8 +3407,10 @@ fn companion_console_extension_point(status: &CompanionStatusResponse) -> Extens
|
||||||
|
|
||||||
async fn list_tickets(
|
async fn list_tickets(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
|
Query(query): Query<TicketKanbanQuery>,
|
||||||
) -> ApiResult<Json<ListResponse<crate::records::TicketSummary>>> {
|
) -> ApiResult<Json<ListResponse<crate::records::TicketSummary>>> {
|
||||||
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 {
|
let ProjectRecordList {
|
||||||
items,
|
items,
|
||||||
invalid_records,
|
invalid_records,
|
||||||
|
|
|
||||||
|
|
@ -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",
|
"Tickets sidebar section should link to the workspace Tickets surface",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
ticketsLoad.includes('workspaceApiPath(params.workspaceId, "/tickets")') &&
|
ticketsLoad.includes('`${workspaceApiPath(params.workspaceId, "/tickets")}?limit=1000`') &&
|
||||||
ticketsPage.includes("This surface is read-only") &&
|
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}`)"),
|
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(
|
assert(
|
||||||
ticketDetailLoad.includes("`/tickets/${encodeURIComponent(ticketId)}`") &&
|
ticketDetailLoad.includes("`/tickets/${encodeURIComponent(ticketId)}`") &&
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,116 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { formatDate, workspaceRoute } from '$lib/workspace/api/http';
|
import { formatDate, workspaceRoute } from '$lib/workspace/api/http';
|
||||||
|
import type { TicketSummary } from '$lib/workspace/sidebar/types';
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
|
type SortKey = 'title' | 'state' | 'priority' | 'updated_at' | 'queued_at' | 'id';
|
||||||
|
type SortDirection = 'asc' | 'desc';
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
let { data }: PageProps = $props();
|
||||||
|
|
||||||
|
let query = $state('');
|
||||||
|
let stateFilter = $state('all');
|
||||||
|
let priorityFilter = $state('all');
|
||||||
|
let queuedFilter = $state('all');
|
||||||
|
let sortKey = $state<SortKey>('updated_at');
|
||||||
|
let sortDirection = $state<SortDirection>('desc');
|
||||||
|
|
||||||
|
const tickets = $derived(data.tickets.data?.items ?? []);
|
||||||
|
const states = $derived(uniqueValues(tickets.map((ticket) => ticket.state)));
|
||||||
|
const priorities = $derived(uniqueValues(tickets.map((ticket) => ticket.priority).filter(isPresent)));
|
||||||
|
const filteredTickets = $derived(filterTickets(tickets));
|
||||||
|
const visibleTickets = $derived(sortTickets(filteredTickets));
|
||||||
|
|
||||||
|
function isPresent(value: string | null | undefined): value is string {
|
||||||
|
return Boolean(value && value.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueValues(values: string[]): string[] {
|
||||||
|
return [...new Set(values.filter((value) => value.trim()))].sort((left, right) =>
|
||||||
|
left.localeCompare(right),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterTickets(items: TicketSummary[]): TicketSummary[] {
|
||||||
|
const needle = query.trim().toLowerCase();
|
||||||
|
return items.filter((ticket) => {
|
||||||
|
if (stateFilter !== 'all' && ticket.state !== stateFilter) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (priorityFilter !== 'all' && (ticket.priority ?? '') !== priorityFilter) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (queuedFilter === 'queued' && !ticket.queued_at) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (queuedFilter === 'unqueued' && ticket.queued_at) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!needle) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return [ticket.id, ticket.title, ticket.state, ticket.priority, ticket.queued_by, ticket.record_source]
|
||||||
|
.filter(isPresent)
|
||||||
|
.some((value) => value.toLowerCase().includes(needle));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortTickets(items: TicketSummary[]): TicketSummary[] {
|
||||||
|
return [...items].sort((left, right) => {
|
||||||
|
const result = compareTicketValues(left, right, sortKey);
|
||||||
|
return sortDirection === 'asc' ? result : -result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareTicketValues(left: TicketSummary, right: TicketSummary, key: SortKey): number {
|
||||||
|
if (key === 'updated_at' || key === 'queued_at') {
|
||||||
|
return compareDate(left[key], right[key]);
|
||||||
|
}
|
||||||
|
return compareText(ticketValue(left, key), ticketValue(right, key));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ticketValue(ticket: TicketSummary, key: SortKey): string | null | undefined {
|
||||||
|
if (key === 'id') return ticket.id;
|
||||||
|
if (key === 'title') return ticket.title;
|
||||||
|
if (key === 'state') return ticket.state;
|
||||||
|
if (key === 'priority') return ticket.priority;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareText(left: string | null | undefined, right: string | null | undefined): number {
|
||||||
|
const leftText = left?.trim() ?? '';
|
||||||
|
const rightText = right?.trim() ?? '';
|
||||||
|
if (!leftText && rightText) return 1;
|
||||||
|
if (leftText && !rightText) return -1;
|
||||||
|
return leftText.localeCompare(rightText);
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareDate(left: string | null | undefined, right: string | null | undefined): number {
|
||||||
|
const leftTime = left ? Date.parse(left) : Number.NEGATIVE_INFINITY;
|
||||||
|
const rightTime = right ? Date.parse(right) : Number.NEGATIVE_INFINITY;
|
||||||
|
return leftTime - rightTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSort(key: SortKey) {
|
||||||
|
if (sortKey === key) {
|
||||||
|
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sortKey = key;
|
||||||
|
sortDirection = key === 'updated_at' || key === 'queued_at' ? 'desc' : 'asc';
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortLabel(key: SortKey): string {
|
||||||
|
if (sortKey !== key) return '';
|
||||||
|
return sortDirection === 'asc' ? '↑' : '↓';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetFilters() {
|
||||||
|
query = '';
|
||||||
|
stateFilter = 'all';
|
||||||
|
priorityFilter = 'all';
|
||||||
|
queuedFilter = 'all';
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
|
@ -10,47 +118,101 @@
|
||||||
<meta name="description" content="Workspace Tickets" />
|
<meta name="description" content="Workspace Tickets" />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card ticket-database-card">
|
||||||
<div class="detail-heading">
|
<div class="detail-heading">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Workspace records</p>
|
<p class="eyebrow">Workspace records</p>
|
||||||
<h2>Tickets</h2>
|
<h2>Tickets</h2>
|
||||||
</div>
|
</div>
|
||||||
{#if data.tickets.data}
|
{#if data.tickets.data}
|
||||||
<span>{data.tickets.data.items.length} ticket{data.tickets.data.items.length === 1 ? '' : 's'}</span>
|
<span>{visibleTickets.length} / {data.tickets.data.items.length} ticket{data.tickets.data.items.length === 1 ? '' : 's'}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="section-note">
|
<p class="section-note">
|
||||||
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.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{#if data.tickets.data}
|
{#if data.tickets.data}
|
||||||
{#if data.tickets.data.items.length === 0}
|
{#if data.tickets.data.items.length === 0}
|
||||||
<p>No Ticket records are present.</p>
|
<p>No Ticket records are present.</p>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="ticket-list" aria-label="Workspace Tickets">
|
<div class="ticket-database-toolbar" aria-label="Ticket table controls">
|
||||||
{#each data.tickets.data.items as ticket (ticket.id)}
|
<label class="ticket-filter ticket-search">
|
||||||
<a class="ticket-row" href={workspaceRoute(data.workspaceId, `/tickets/${ticket.id}`)}>
|
<span>Search</span>
|
||||||
<div class="ticket-main">
|
<input bind:value={query} type="search" placeholder="Title, id, state, source…" />
|
||||||
<div class="ticket-title-row">
|
</label>
|
||||||
<strong class="ticket-title">{ticket.title}</strong>
|
<label class="ticket-filter">
|
||||||
<span class="state-pill">{ticket.state}</span>
|
<span>State</span>
|
||||||
</div>
|
<select bind:value={stateFilter}>
|
||||||
<p class="ticket-summary">
|
<option value="all">All states</option>
|
||||||
{ticket.priority ? `${ticket.priority} priority` : 'priority unspecified'} · {ticket.record_source ?? 'ticket backend'}
|
{#each states as state}
|
||||||
</p>
|
<option value={state}>{state}</option>
|
||||||
</div>
|
{/each}
|
||||||
<div class="ticket-meta" aria-label="Ticket metadata">
|
</select>
|
||||||
<span>Updated {ticket.updated_at ? formatDate(ticket.updated_at) : 'unknown'}</span>
|
</label>
|
||||||
{#if ticket.queued_at}
|
<label class="ticket-filter">
|
||||||
<span>Queued {formatDate(ticket.queued_at)}</span>
|
<span>Priority</span>
|
||||||
{/if}
|
<select bind:value={priorityFilter}>
|
||||||
<code>{ticket.id}</code>
|
<option value="all">All priorities</option>
|
||||||
</div>
|
{#each priorities as priority}
|
||||||
</a>
|
<option value={priority}>{priority}</option>
|
||||||
{/each}
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="ticket-filter">
|
||||||
|
<span>Queue</span>
|
||||||
|
<select bind:value={queuedFilter}>
|
||||||
|
<option value="all">All</option>
|
||||||
|
<option value="queued">Queued</option>
|
||||||
|
<option value="unqueued">Unqueued</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button class="secondary-button" type="button" onclick={resetFilters}>Reset</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="ticket-table-wrap" aria-label="Workspace Tickets table">
|
||||||
|
<table class="ticket-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><button type="button" onclick={() => toggleSort('title')}>Title {sortLabel('title')}</button></th>
|
||||||
|
<th><button type="button" onclick={() => toggleSort('state')}>State {sortLabel('state')}</button></th>
|
||||||
|
<th><button type="button" onclick={() => toggleSort('priority')}>Priority {sortLabel('priority')}</button></th>
|
||||||
|
<th><button type="button" onclick={() => toggleSort('updated_at')}>Updated {sortLabel('updated_at')}</button></th>
|
||||||
|
<th><button type="button" onclick={() => toggleSort('queued_at')}>Queued {sortLabel('queued_at')}</button></th>
|
||||||
|
<th><button type="button" onclick={() => toggleSort('id')}>ID {sortLabel('id')}</button></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each visibleTickets as ticket (ticket.id)}
|
||||||
|
<tr>
|
||||||
|
<td class="ticket-title-cell">
|
||||||
|
<a href={workspaceRoute(data.workspaceId, `/tickets/${ticket.id}`)}>{ticket.title}</a>
|
||||||
|
<span>{ticket.record_source ?? 'ticket backend'}</span>
|
||||||
|
</td>
|
||||||
|
<td><span class="state-pill">{ticket.state}</span></td>
|
||||||
|
<td>{ticket.priority || '—'}</td>
|
||||||
|
<td>{ticket.updated_at ? formatDate(ticket.updated_at) : 'unknown'}</td>
|
||||||
|
<td>
|
||||||
|
{#if ticket.queued_at}
|
||||||
|
<span>{formatDate(ticket.queued_at)}</span>
|
||||||
|
{#if ticket.queued_by}
|
||||||
|
<small>{ticket.queued_by}</small>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<span class="muted">—</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td><code>{ticket.id}</code></td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if visibleTickets.length === 0}
|
||||||
|
<p class="section-note">No tickets match the current filters.</p>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if data.tickets.data.invalid_records.length > 0}
|
{#if data.tickets.data.invalid_records.length > 0}
|
||||||
|
|
@ -62,3 +224,124 @@
|
||||||
<p>Waiting for <code>/api/w/{data.workspaceId}/tickets</code>…</p>
|
<p>Waiting for <code>/api/w/{data.workspaceId}/tickets</code>…</p>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.ticket-database-card {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-database-toolbar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(16rem, 1.8fr) repeat(3, minmax(9rem, 1fr)) auto;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: end;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-filter {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
color: var(--muted-foreground, #667085);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-filter input,
|
||||||
|
.ticket-filter select {
|
||||||
|
min-height: 2.35rem;
|
||||||
|
border: 1px solid var(--border, #d0d5dd);
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
background: var(--surface, #fff);
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: normal;
|
||||||
|
padding: 0 0.7rem;
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-button {
|
||||||
|
min-height: 2.35rem;
|
||||||
|
border: 1px solid var(--border, #d0d5dd);
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
background: var(--surface, #fff);
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-table-wrap {
|
||||||
|
border: 1px solid var(--border, #d0d5dd);
|
||||||
|
border-radius: 0.8rem;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-table {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 58rem;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-table th,
|
||||||
|
.ticket-table td {
|
||||||
|
border-bottom: 1px solid var(--border, #eaecf0);
|
||||||
|
padding: 0.72rem 0.8rem;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-table th {
|
||||||
|
background: var(--subtle-surface, #f9fafb);
|
||||||
|
color: var(--muted-foreground, #667085);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
position: sticky;
|
||||||
|
text-transform: uppercase;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-table th button {
|
||||||
|
all: unset;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-table tbody tr:hover {
|
||||||
|
background: var(--hover-surface, #f9fafb);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-title-cell {
|
||||||
|
min-width: 22rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-title-cell a {
|
||||||
|
color: inherit;
|
||||||
|
display: block;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-title-cell a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-title-cell span,
|
||||||
|
.ticket-table small,
|
||||||
|
.muted {
|
||||||
|
color: var(--muted-foreground, #667085);
|
||||||
|
display: block;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.ticket-database-toolbar {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import type { PageLoad } from "./$types";
|
||||||
export const load = (async ({ fetch, params }) => {
|
export const load = (async ({ fetch, params }) => {
|
||||||
const tickets = await loadJson<TicketListResponse>(
|
const tickets = await loadJson<TicketListResponse>(
|
||||||
fetch,
|
fetch,
|
||||||
workspaceApiPath(params.workspaceId, "/tickets"),
|
`${workspaceApiPath(params.workspaceId, "/tickets")}?limit=1000`,
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user