web: align ticket table with panel order

This commit is contained in:
Keisuke Hirata 2026-07-24 12:46:17 +09:00
parent 7a28395d75
commit b9067a8110
No known key found for this signature in database
4 changed files with 80 additions and 13 deletions

View File

@ -3,7 +3,10 @@ use std::path::{Path, PathBuf};
use project_record::validate_record_id;
use serde::{Deserialize, Serialize};
use ticket::{SqliteTicketBackend, TicketBackend, TicketIdOrSlug, TicketListQuery};
use ticket::{
SqliteTicketBackend, TicketBackend, TicketIdOrSlug, TicketListQuery,
TicketWorkspaceActionPriority, project_ticket_workspace_item,
};
use crate::{Error, Result};
@ -34,11 +37,13 @@ impl LocalProjectRecordReader {
}
pub fn list_tickets(&self, limit: usize) -> Result<ProjectRecordList<TicketSummary>> {
let mut items = self
let mut items = Vec::new();
for item in self.ticket_backend.list(TicketListQuery::all())? {
let ticket = self
.ticket_backend
.list(TicketListQuery::all())?
.into_iter()
.map(|item| TicketSummary {
.show(TicketIdOrSlug::Id(item.id.clone()))?;
let projection = project_ticket_workspace_item(&item, &ticket.relations.blockers, None);
items.push(TicketSummary {
id: item.id,
title: item.title,
state: item.workflow_state.as_str().to_string(),
@ -46,15 +51,17 @@ impl LocalProjectRecordReader {
updated_at: item.updated_at,
queued_by: item.queued_by,
queued_at: item.queued_at,
workspace_action_priority: workspace_action_priority_name(projection.priority)
.to_string(),
record_source: "sqlite_yoi_ticket".to_string(),
})
.collect::<Vec<_>>();
});
}
items.sort_by(|a, b| {
b.updated_at
.cmp(&a.updated_at)
.then_with(|| a.id.cmp(&b.id))
});
items.truncate(limit.min(200));
items.truncate(limit);
Ok(ProjectRecordList {
items,
invalid_records: Vec::new(),
@ -148,6 +155,14 @@ impl LocalProjectRecordReader {
}
}
fn workspace_action_priority_name(priority: TicketWorkspaceActionPriority) -> &'static str {
match priority {
TicketWorkspaceActionPriority::ReadyForQueue => "ready_for_queue",
TicketWorkspaceActionPriority::ActiveWork => "active_work",
TicketWorkspaceActionPriority::Background => "background",
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProjectRecordList<T> {
pub items: Vec<T>,
@ -170,6 +185,7 @@ pub struct TicketSummary {
pub updated_at: Option<String>,
pub queued_by: Option<String>,
pub queued_at: Option<String>,
pub workspace_action_priority: String,
pub record_source: String,
}
@ -304,6 +320,10 @@ mod tests {
assert_eq!(tickets.items[0].record_source, "sqlite_yoi_ticket");
assert_eq!(tickets.items[0].id, "00000000001J2");
assert_eq!(tickets.items[0].state, "ready");
assert_eq!(
tickets.items[0].workspace_action_priority,
"ready_for_queue"
);
let ticket = reader.ticket("00000000001J2").unwrap();
assert!(ticket.body.contains("Ticket body"));

View File

@ -93,6 +93,9 @@ Deno.test("workspace Tickets surface uses read-only Backend Ticket APIs", async
ticketsLoad.includes('`${workspaceApiPath(params.workspaceId, "/tickets")}?limit=1000`') &&
ticketsPage.includes("Notion-style filtering and sorting") &&
ticketsPage.includes("toggleSort('updated_at')") &&
ticketsPage.includes("bind:value={visibilityFilter}") &&
ticketsPage.includes("sortKey = $state<SortKey>('panel')") &&
ticketsPage.includes("workspace_action_priority") &&
ticketsPage.includes("bind:value={stateFilter}") &&
ticketsPage.includes("workspaceRoute(data.workspaceId, `/tickets/${ticket.id}`)"),
"Tickets list should read the workspace-scoped Ticket API and expose sortable/filterable table links",

View File

@ -326,6 +326,7 @@ export type TicketSummary = {
updated_at?: string | null;
queued_by?: string | null;
queued_at?: string | null;
workspace_action_priority?: 'ready_for_queue' | 'active_work' | 'background' | null;
record_source?: string;
};

View File

@ -3,17 +3,18 @@
import type { TicketSummary } from '$lib/workspace/sidebar/types';
import type { PageProps } from './$types';
type SortKey = 'title' | 'state' | 'priority' | 'updated_at' | 'queued_at' | 'id';
type SortKey = 'panel' | 'title' | 'state' | 'priority' | 'updated_at' | 'queued_at' | 'id';
type SortDirection = 'asc' | 'desc';
let { data }: PageProps = $props();
let query = $state('');
let visibilityFilter = $state<'open' | 'closed' | 'all'>('open');
let stateFilter = $state('all');
let priorityFilter = $state('all');
let queuedFilter = $state('all');
let sortKey = $state<SortKey>('updated_at');
let sortDirection = $state<SortDirection>('desc');
let sortKey = $state<SortKey>('panel');
let sortDirection = $state<SortDirection>('asc');
const tickets = $derived(data.tickets.data?.items ?? []);
const states = $derived(uniqueValues(tickets.map((ticket) => ticket.state)));
@ -34,6 +35,12 @@
function filterTickets(items: TicketSummary[]): TicketSummary[] {
const needle = query.trim().toLowerCase();
return items.filter((ticket) => {
if (visibilityFilter === 'open' && ticket.state === 'closed') {
return false;
}
if (visibilityFilter === 'closed' && ticket.state !== 'closed') {
return false;
}
if (stateFilter !== 'all' && ticket.state !== stateFilter) {
return false;
}
@ -63,12 +70,36 @@
}
function compareTicketValues(left: TicketSummary, right: TicketSummary, key: SortKey): number {
if (key === 'panel') {
return comparePanelOrder(left, right);
}
if (key === 'updated_at' || key === 'queued_at') {
return compareDate(left[key], right[key]);
}
return compareText(ticketValue(left, key), ticketValue(right, key));
}
function comparePanelOrder(left: TicketSummary, right: TicketSummary): number {
return compareNumber(panelActionPriority(left), panelActionPriority(right))
|| compareDate(right.updated_at, left.updated_at)
|| compareText(left.title, right.title);
}
function panelActionPriority(ticket: TicketSummary): number {
if (ticket.workspace_action_priority) {
if (ticket.workspace_action_priority === 'ready_for_queue') return 0;
if (ticket.workspace_action_priority === 'active_work') return 1;
if (ticket.workspace_action_priority === 'background') return 2;
}
if (ticket.state === 'ready') return 0;
if (ticket.state === 'queued' || ticket.state === 'inprogress') return 1;
return 2;
}
function compareNumber(left: number, right: number): number {
return left - right;
}
function ticketValue(ticket: TicketSummary, key: SortKey): string | null | undefined {
if (key === 'id') return ticket.id;
if (key === 'title') return ticket.title;
@ -107,9 +138,12 @@
function resetFilters() {
query = '';
visibilityFilter = 'open';
stateFilter = 'all';
priorityFilter = 'all';
queuedFilter = 'all';
sortKey = 'panel';
sortDirection = 'asc';
}
</script>
@ -142,6 +176,14 @@
<span>Search</span>
<input bind:value={query} type="search" placeholder="Title, id, state, source…" />
</label>
<label class="ticket-filter">
<span>Visibility</span>
<select bind:value={visibilityFilter}>
<option value="open">Open</option>
<option value="closed">Closed</option>
<option value="all">All</option>
</select>
</label>
<label class="ticket-filter">
<span>State</span>
<select bind:value={stateFilter}>
@ -168,6 +210,7 @@
<option value="unqueued">Unqueued</option>
</select>
</label>
<button class="secondary-button" type="button" onclick={() => toggleSort('panel')}>Panel order {sortLabel('panel')}</button>
<button class="secondary-button" type="button" onclick={resetFilters}>Reset</button>
</div>
@ -232,7 +275,7 @@
.ticket-database-toolbar {
display: grid;
grid-template-columns: minmax(16rem, 1.8fr) repeat(3, minmax(9rem, 1fr)) auto;
grid-template-columns: minmax(16rem, 1.8fr) repeat(4, minmax(9rem, 1fr)) auto auto;
gap: 0.75rem;
align-items: end;
margin: 1rem 0;