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 project_record::validate_record_id;
use serde::{Deserialize, Serialize}; 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}; use crate::{Error, Result};
@ -34,11 +37,13 @@ impl LocalProjectRecordReader {
} }
pub fn list_tickets(&self, limit: usize) -> Result<ProjectRecordList<TicketSummary>> { 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 .ticket_backend
.list(TicketListQuery::all())? .show(TicketIdOrSlug::Id(item.id.clone()))?;
.into_iter() let projection = project_ticket_workspace_item(&item, &ticket.relations.blockers, None);
.map(|item| TicketSummary { items.push(TicketSummary {
id: item.id, id: item.id,
title: item.title, title: item.title,
state: item.workflow_state.as_str().to_string(), state: item.workflow_state.as_str().to_string(),
@ -46,15 +51,17 @@ impl LocalProjectRecordReader {
updated_at: item.updated_at, updated_at: item.updated_at,
queued_by: item.queued_by, queued_by: item.queued_by,
queued_at: item.queued_at, queued_at: item.queued_at,
workspace_action_priority: workspace_action_priority_name(projection.priority)
.to_string(),
record_source: "sqlite_yoi_ticket".to_string(), record_source: "sqlite_yoi_ticket".to_string(),
}) });
.collect::<Vec<_>>(); }
items.sort_by(|a, b| { items.sort_by(|a, b| {
b.updated_at b.updated_at
.cmp(&a.updated_at) .cmp(&a.updated_at)
.then_with(|| a.id.cmp(&b.id)) .then_with(|| a.id.cmp(&b.id))
}); });
items.truncate(limit.min(200)); items.truncate(limit);
Ok(ProjectRecordList { Ok(ProjectRecordList {
items, items,
invalid_records: Vec::new(), 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)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProjectRecordList<T> { pub struct ProjectRecordList<T> {
pub items: Vec<T>, pub items: Vec<T>,
@ -170,6 +185,7 @@ pub struct TicketSummary {
pub updated_at: Option<String>, pub updated_at: Option<String>,
pub queued_by: Option<String>, pub queued_by: Option<String>,
pub queued_at: Option<String>, pub queued_at: Option<String>,
pub workspace_action_priority: String,
pub record_source: 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].record_source, "sqlite_yoi_ticket");
assert_eq!(tickets.items[0].id, "00000000001J2"); assert_eq!(tickets.items[0].id, "00000000001J2");
assert_eq!(tickets.items[0].state, "ready"); assert_eq!(tickets.items[0].state, "ready");
assert_eq!(
tickets.items[0].workspace_action_priority,
"ready_for_queue"
);
let ticket = reader.ticket("00000000001J2").unwrap(); let ticket = reader.ticket("00000000001J2").unwrap();
assert!(ticket.body.contains("Ticket body")); 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`') && ticketsLoad.includes('`${workspaceApiPath(params.workspaceId, "/tickets")}?limit=1000`') &&
ticketsPage.includes("Notion-style filtering and sorting") && ticketsPage.includes("Notion-style filtering and sorting") &&
ticketsPage.includes("toggleSort('updated_at')") && 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("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 expose sortable/filterable table links", "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; updated_at?: string | null;
queued_by?: string | null; queued_by?: string | null;
queued_at?: string | null; queued_at?: string | null;
workspace_action_priority?: 'ready_for_queue' | 'active_work' | 'background' | null;
record_source?: string; record_source?: string;
}; };

View File

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