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 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"));
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user