web: align ticket table with panel order
This commit is contained in:
parent
7a28395d75
commit
b9067a8110
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user