feat: paginate ticket workflow lanes
This commit is contained in:
@@ -218,19 +218,23 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as
|
|||||||
"Tickets and Objectives should each be a single sidebar link",
|
"Tickets and Objectives should each be a single sidebar link",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
ticketsLoad.includes("?limit=1000") &&
|
!ticketsLoad.includes("?limit=1000") &&
|
||||||
ticketsPage.includes("data.tickets.data?.items") &&
|
ticketsLoad.includes('workspaceApiPath(workspaceId, "/tickets/query")') &&
|
||||||
ticketsPage.includes("ticketLanes(tickets)") &&
|
ticketsLoad.includes("ticketLaneDefinitions()") &&
|
||||||
|
ticketsLoad.includes("ticketLaneQuery(lane)") &&
|
||||||
ticketsPage.includes('class="ticket-kanban"') &&
|
ticketsPage.includes('class="ticket-kanban"') &&
|
||||||
ticketsPage.includes("lane.tickets"),
|
ticketsPage.includes('class="ticket-lane-cards"') &&
|
||||||
"Tickets list should load every workflow state and render a workspace Kanban board",
|
ticketsPage.includes("handleLaneScroll") &&
|
||||||
|
ticketsPage.includes("Loading 30 more"),
|
||||||
|
"Tickets list should query and incrementally scroll each Kanban lane",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
ticketPanelModel.includes('label: "Ready + Planning"') &&
|
ticketPanelModel.includes('label: "Ready + Planning"') &&
|
||||||
ticketPanelModel.includes('label: "In progress + Queued"') &&
|
ticketPanelModel.includes('label: "In progress + Queued"') &&
|
||||||
ticketPanelModel.includes('label: "Done + Closed"') &&
|
ticketPanelModel.includes('label: "Done + Closed"') &&
|
||||||
ticketPanelModel.includes("updatedAt(right) - updatedAt(left)"),
|
ticketPanelModel.includes("TICKET_LANE_PAGE_SIZE = 30") &&
|
||||||
"Ticket Kanban should combine related states and sort state priority before recency",
|
ticketPanelModel.includes('sort: "updated_desc"'),
|
||||||
|
"Ticket Kanban should combine related states into independent cursor pages",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
generatedTicketApi.includes("Generated from yoi-workspace-server") &&
|
generatedTicketApi.includes("Generated from yoi-workspace-server") &&
|
||||||
@@ -249,7 +253,9 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as
|
|||||||
ticketDetailPage.includes('mutate("state", "/state"') &&
|
ticketDetailPage.includes('mutate("state", "/state"') &&
|
||||||
ticketDetailPage.includes('mutate("queue", "/queue"') &&
|
ticketDetailPage.includes('mutate("queue", "/queue"') &&
|
||||||
!ticketDetailPage.includes("/merge-request/merge") &&
|
!ticketDetailPage.includes("/merge-request/merge") &&
|
||||||
ticketDetailPage.includes("merged_result_commit") &&
|
ticketDetailPage.includes("mergeRequest.selector_from") &&
|
||||||
|
ticketDetailPage.includes("currentReview?.kind") &&
|
||||||
|
ticketDetailPage.includes("mergeEvent?.kind") &&
|
||||||
!ticketDetailPage.includes('mutate("review", "/review"') &&
|
!ticketDetailPage.includes('mutate("review", "/review"') &&
|
||||||
ticketDetailPage.includes('mutate("close", "/close"') &&
|
ticketDetailPage.includes('mutate("close", "/close"') &&
|
||||||
ticketDetailPage.includes("ticketWorkerLaunchHref") &&
|
ticketDetailPage.includes("ticketWorkerLaunchHref") &&
|
||||||
|
|||||||
@@ -121,18 +121,25 @@
|
|||||||
}
|
}
|
||||||
.ticket-kanban {
|
.ticket-kanban {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
align-items: stretch;
|
||||||
grid-template-columns: repeat(3, minmax(18rem, 1fr));
|
grid-template-columns: repeat(3, minmax(18rem, 1fr));
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
padding-bottom: var(--space-3);
|
padding-bottom: var(--space-3);
|
||||||
}
|
}
|
||||||
.ticket-lane {
|
.ticket-lane {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: calc(100dvh - 15rem);
|
||||||
min-height: 24rem;
|
min-height: 24rem;
|
||||||
|
max-height: 48rem;
|
||||||
|
overflow: hidden;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 0.8rem;
|
border-radius: 0.8rem;
|
||||||
background: color-mix(in srgb, var(--bg-raised) 55%, transparent);
|
background: color-mix(in srgb, var(--bg-raised) 55%, transparent);
|
||||||
}
|
}
|
||||||
.ticket-lane-header {
|
.ticket-lane-header {
|
||||||
|
flex: 0 0 auto;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
@@ -167,9 +174,29 @@
|
|||||||
}
|
}
|
||||||
.ticket-lane-cards {
|
.ticket-lane-cards {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
flex: 1 1 auto;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
gap: 0.55rem;
|
gap: 0.55rem;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
padding: 0.6rem;
|
padding: 0.6rem;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
|
.ticket-lane-load-status {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 0.5rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.ticket-lane-load-error {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: #d66;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
.ticket-lane-load-error button {
|
||||||
|
justify-self: start;
|
||||||
}
|
}
|
||||||
.ticket-card {
|
.ticket-card {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
|
appendUniqueTicketSummaries,
|
||||||
|
TICKET_LANE_PAGE_SIZE,
|
||||||
|
ticketLaneDefinitions,
|
||||||
|
ticketLaneQuery,
|
||||||
ticketLanes,
|
ticketLanes,
|
||||||
ticketWorkerLaunchHref,
|
ticketWorkerLaunchHref,
|
||||||
ticketWorkerMessage,
|
ticketWorkerMessage,
|
||||||
@@ -79,6 +83,49 @@ Deno.test("ticketLanes combines workflow states and sorts by state then update t
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("ticket lane queries request independent pages of 30", () => {
|
||||||
|
const [readyPlanning, inprogressQueued, doneClosed] = ticketLaneDefinitions();
|
||||||
|
|
||||||
|
assertEquals(TICKET_LANE_PAGE_SIZE, 30);
|
||||||
|
assertEquals(ticketLaneQuery(readyPlanning).states, ["ready", "planning"]);
|
||||||
|
assertEquals(ticketLaneQuery(inprogressQueued).states, [
|
||||||
|
"inprogress",
|
||||||
|
"queued",
|
||||||
|
]);
|
||||||
|
assertEquals(ticketLaneQuery(doneClosed, "next-page"), {
|
||||||
|
attention: [],
|
||||||
|
cursor: "next-page",
|
||||||
|
event_kinds: [],
|
||||||
|
evidence: [],
|
||||||
|
limit: 30,
|
||||||
|
linked_objective_id: null,
|
||||||
|
query: null,
|
||||||
|
related_ticket_id: null,
|
||||||
|
relation_kind: null,
|
||||||
|
review_status: null,
|
||||||
|
sort: "updated_desc",
|
||||||
|
states: ["done", "closed"],
|
||||||
|
updated_after: null,
|
||||||
|
updated_before: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("incremental Ticket pages preserve order and discard duplicate ids", () => {
|
||||||
|
const current = [
|
||||||
|
{ id: "first", title: "First", state: "ready", priority: "1" },
|
||||||
|
{ id: "second", title: "Second", state: "planning", priority: "2" },
|
||||||
|
] as TicketSummary[];
|
||||||
|
const incoming = [
|
||||||
|
{ id: "second", title: "Duplicate", state: "planning", priority: "2" },
|
||||||
|
{ id: "third", title: "Third", state: "planning", priority: "3" },
|
||||||
|
] as TicketSummary[];
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
appendUniqueTicketSummaries(current, incoming).map((ticket) => ticket.id),
|
||||||
|
["first", "second", "third"],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test("ticket worker launch uses the common Worker route and bounded Ticket context", () => {
|
Deno.test("ticket worker launch uses the common Worker route and bounded Ticket context", () => {
|
||||||
const ticket = {
|
const ticket = {
|
||||||
id: "00001KYRRDVH9",
|
id: "00001KYRRDVH9",
|
||||||
@@ -113,10 +160,22 @@ Deno.test("ticket panel starts the Orchestrator explicitly and gates orchestrati
|
|||||||
"src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte",
|
"src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte",
|
||||||
);
|
);
|
||||||
|
|
||||||
assertIncludes(panelSource, 'workspaceApiPath(data.workspaceId, "/orchestrator")');
|
assertIncludes(
|
||||||
|
panelSource,
|
||||||
|
'workspaceApiPath(data.workspaceId, "/orchestrator")',
|
||||||
|
);
|
||||||
assertIncludes(panelSource, '{ method: "POST" }');
|
assertIncludes(panelSource, '{ method: "POST" }');
|
||||||
assertIncludes(panelSource, "Start Orchestrator");
|
assertIncludes(panelSource, "Start Orchestrator");
|
||||||
assertIncludes(panelSource, "orchestrator.data?.online");
|
assertIncludes(panelSource, "orchestrator.data?.online");
|
||||||
|
assertIncludes(
|
||||||
|
panelSource,
|
||||||
|
'workspaceApiPath(data.workspaceId, "/tickets/query")',
|
||||||
|
);
|
||||||
|
assertIncludes(
|
||||||
|
panelSource,
|
||||||
|
"onscroll={(event) => handleLaneScroll(event, lane.id)}",
|
||||||
|
);
|
||||||
|
assertIncludes(panelSource, "Loading 30 more…");
|
||||||
assertIncludes(detailSource, "{#if orchestratorOnline}");
|
assertIncludes(detailSource, "{#if orchestratorOnline}");
|
||||||
assertIncludes(detailSource, "!orchestratorOnline");
|
assertIncludes(detailSource, "!orchestratorOnline");
|
||||||
assertIncludes(detailSource, "Orchestrator offline");
|
assertIncludes(detailSource, "Orchestrator offline");
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import type { TicketDetail, TicketSummary } from "$lib/generated/ticket-api";
|
import type {
|
||||||
|
TicketDetail,
|
||||||
|
TicketQueryItem,
|
||||||
|
TicketQueryRequest,
|
||||||
|
TicketSummary,
|
||||||
|
} from "$lib/generated/ticket-api";
|
||||||
|
|
||||||
export const TICKET_STATES = [
|
export const TICKET_STATES = [
|
||||||
"planning",
|
"planning",
|
||||||
@@ -47,6 +52,15 @@ const LANE_DEFINITIONS = [
|
|||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
export const TICKET_LANE_PAGE_SIZE = 30;
|
||||||
|
|
||||||
|
export type TicketLaneDefinition = (typeof LANE_DEFINITIONS)[number];
|
||||||
|
export type TicketLaneId = TicketLaneDefinition["id"];
|
||||||
|
export type TicketCardSummary = Pick<
|
||||||
|
TicketSummary,
|
||||||
|
"id" | "title" | "state" | "priority" | "updated_at"
|
||||||
|
>;
|
||||||
|
|
||||||
const STATE_SORT_ORDER = new Map<string, number>([
|
const STATE_SORT_ORDER = new Map<string, number>([
|
||||||
["ready", 0],
|
["ready", 0],
|
||||||
["planning", 1],
|
["planning", 1],
|
||||||
@@ -57,19 +71,74 @@ const STATE_SORT_ORDER = new Map<string, number>([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
export type TicketLane = {
|
export type TicketLane = {
|
||||||
id: string;
|
id: TicketLaneId;
|
||||||
label: string;
|
label: string;
|
||||||
states: readonly TicketState[];
|
states: readonly TicketState[];
|
||||||
tickets: TicketSummary[];
|
tickets: TicketCardSummary[];
|
||||||
};
|
};
|
||||||
|
|
||||||
function updatedAt(ticket: TicketSummary): number {
|
export function ticketLaneDefinitions(): readonly TicketLaneDefinition[] {
|
||||||
|
return LANE_DEFINITIONS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ticketLaneQuery(
|
||||||
|
lane: { states: readonly TicketState[] },
|
||||||
|
cursor: string | null = null,
|
||||||
|
): TicketQueryRequest {
|
||||||
|
return {
|
||||||
|
attention: [],
|
||||||
|
cursor,
|
||||||
|
event_kinds: [],
|
||||||
|
evidence: [],
|
||||||
|
limit: TICKET_LANE_PAGE_SIZE,
|
||||||
|
linked_objective_id: null,
|
||||||
|
query: null,
|
||||||
|
related_ticket_id: null,
|
||||||
|
relation_kind: null,
|
||||||
|
review_status: null,
|
||||||
|
sort: "updated_desc",
|
||||||
|
states: [...lane.states],
|
||||||
|
updated_after: null,
|
||||||
|
updated_before: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ticketSummaryFromQueryItem(
|
||||||
|
item: TicketQueryItem,
|
||||||
|
): TicketCardSummary {
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
priority: item.priority === null ? "normal" : String(item.priority),
|
||||||
|
state: item.state,
|
||||||
|
title: item.title,
|
||||||
|
updated_at: item.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appendUniqueTicketSummaries(
|
||||||
|
current: TicketCardSummary[],
|
||||||
|
incoming: TicketCardSummary[],
|
||||||
|
): TicketCardSummary[] {
|
||||||
|
const ids = new Set(current.map((ticket) => ticket.id));
|
||||||
|
return [
|
||||||
|
...current,
|
||||||
|
...incoming.filter((ticket) => {
|
||||||
|
if (ids.has(ticket.id)) return false;
|
||||||
|
ids.add(ticket.id);
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatedAt(ticket: TicketCardSummary): number {
|
||||||
if (!ticket.updated_at) return 0;
|
if (!ticket.updated_at) return 0;
|
||||||
const parsed = Date.parse(ticket.updated_at);
|
const parsed = Date.parse(ticket.updated_at);
|
||||||
return Number.isNaN(parsed) ? 0 : parsed;
|
return Number.isNaN(parsed) ? 0 : parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sortTickets(tickets: TicketSummary[]): TicketSummary[] {
|
export function sortTickets(
|
||||||
|
tickets: TicketCardSummary[],
|
||||||
|
): TicketCardSummary[] {
|
||||||
return [...tickets].sort((left, right) => {
|
return [...tickets].sort((left, right) => {
|
||||||
const stateDelta = (STATE_SORT_ORDER.get(left.state) ?? 99) -
|
const stateDelta = (STATE_SORT_ORDER.get(left.state) ?? 99) -
|
||||||
(STATE_SORT_ORDER.get(right.state) ?? 99);
|
(STATE_SORT_ORDER.get(right.state) ?? 99);
|
||||||
@@ -80,7 +149,7 @@ export function sortTickets(tickets: TicketSummary[]): TicketSummary[] {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ticketLanes(tickets: TicketSummary[]): TicketLane[] {
|
export function ticketLanes(tickets: TicketCardSummary[]): TicketLane[] {
|
||||||
return LANE_DEFINITIONS.map((definition) => ({
|
return LANE_DEFINITIONS.map((definition) => ({
|
||||||
...definition,
|
...definition,
|
||||||
tickets: sortTickets(
|
tickets: sortTickets(
|
||||||
|
|||||||
@@ -1,31 +1,50 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { untrack } from "svelte";
|
import { untrack } from "svelte";
|
||||||
|
import type { TicketQueryResponse } from "$lib/generated/ticket-api";
|
||||||
import type { ApiResult } from "$lib/workspace/api/http";
|
import type { ApiResult } from "$lib/workspace/api/http";
|
||||||
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
||||||
import {
|
import {
|
||||||
ticketLanes,
|
appendUniqueTicketSummaries,
|
||||||
|
ticketLaneQuery,
|
||||||
|
ticketSummaryFromQueryItem,
|
||||||
|
type TicketCardSummary,
|
||||||
|
type TicketLaneId,
|
||||||
|
type TicketState,
|
||||||
type WorkspaceOrchestratorStatus,
|
type WorkspaceOrchestratorStatus,
|
||||||
} from "$lib/workspace/tickets/ticket-panel";
|
} from "$lib/workspace/tickets/ticket-panel";
|
||||||
import type {
|
import "$lib/workspace/styles/tickets.css";
|
||||||
TicketListResponse,
|
|
||||||
TicketSummary,
|
type LanePage = {
|
||||||
} from "$lib/workspace/sidebar/types";
|
id: TicketLaneId;
|
||||||
|
label: string;
|
||||||
|
states: TicketState[];
|
||||||
|
tickets: TicketCardSummary[];
|
||||||
|
nextCursor: string | null;
|
||||||
|
hasMore: boolean;
|
||||||
|
error: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
const { data } = $props<{
|
const { data } = $props<{
|
||||||
data: {
|
data: {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
tickets: ApiResult<TicketListResponse>;
|
ticketLanePages: LanePage[];
|
||||||
orchestrator: ApiResult<WorkspaceOrchestratorStatus>;
|
orchestrator: ApiResult<WorkspaceOrchestratorStatus>;
|
||||||
};
|
};
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const initialTickets = untrack(() => data.tickets.data?.items ?? []);
|
let lanes = $state<(LanePage & { loading: boolean })[]>(
|
||||||
let tickets = $state<TicketSummary[]>(initialTickets);
|
untrack(() =>
|
||||||
|
data.ticketLanePages.map((lane: LanePage) => ({ ...lane, loading: false }))
|
||||||
|
),
|
||||||
|
);
|
||||||
let orchestrator = $state<ApiResult<WorkspaceOrchestratorStatus>>(
|
let orchestrator = $state<ApiResult<WorkspaceOrchestratorStatus>>(
|
||||||
untrack(() => data.orchestrator),
|
untrack(() => data.orchestrator),
|
||||||
);
|
);
|
||||||
let orchestratorStarting = $state(false);
|
let orchestratorStarting = $state(false);
|
||||||
let lanes = $derived(ticketLanes(tickets));
|
const displayedTicketCount = $derived(
|
||||||
|
lanes.reduce((count, lane) => count + lane.tickets.length, 0),
|
||||||
|
);
|
||||||
|
const LANE_LOAD_THRESHOLD_PX = 96;
|
||||||
|
|
||||||
async function startOrchestrator() {
|
async function startOrchestrator() {
|
||||||
if (orchestratorStarting || orchestrator.data?.online) return;
|
if (orchestratorStarting || orchestrator.data?.online) return;
|
||||||
@@ -43,6 +62,75 @@
|
|||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleDateString();
|
return Number.isNaN(date.getTime()) ? value : date.toLocaleDateString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateLane(
|
||||||
|
laneId: TicketLaneId,
|
||||||
|
update: (lane: LanePage & { loading: boolean }) =>
|
||||||
|
LanePage & { loading: boolean },
|
||||||
|
): void {
|
||||||
|
lanes = lanes.map((lane) => lane.id === laneId ? update(lane) : lane);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMoreTickets(laneId: TicketLaneId): Promise<void> {
|
||||||
|
const lane = lanes.find((candidate) => candidate.id === laneId);
|
||||||
|
if (!lane || lane.loading || (!lane.hasMore && !lane.error)) return;
|
||||||
|
|
||||||
|
updateLane(laneId, (current) => ({
|
||||||
|
...current,
|
||||||
|
loading: true,
|
||||||
|
error: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
let result: ApiResult<TicketQueryResponse>;
|
||||||
|
try {
|
||||||
|
result = await loadJson<TicketQueryResponse>(
|
||||||
|
fetch,
|
||||||
|
workspaceApiPath(data.workspaceId, "/tickets/query"),
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(ticketLaneQuery(lane, lane.nextCursor)),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
updateLane(laneId, (current) => ({
|
||||||
|
...current,
|
||||||
|
loading: false,
|
||||||
|
error: error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Unable to load more Tickets.",
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.data) {
|
||||||
|
updateLane(laneId, (current) => ({
|
||||||
|
...current,
|
||||||
|
loading: false,
|
||||||
|
error: result.error ?? "Unable to load more Tickets.",
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const incoming = result.data.items.map(ticketSummaryFromQueryItem);
|
||||||
|
updateLane(laneId, (current) => ({
|
||||||
|
...current,
|
||||||
|
tickets: appendUniqueTicketSummaries(current.tickets, incoming),
|
||||||
|
nextCursor: result.data?.page.next_cursor ?? null,
|
||||||
|
hasMore: result.data?.page.has_more ?? false,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLaneScroll(event: Event, laneId: TicketLaneId): void {
|
||||||
|
const element = event.currentTarget as HTMLElement;
|
||||||
|
const distanceFromBottom = element.scrollHeight - element.scrollTop -
|
||||||
|
element.clientHeight;
|
||||||
|
if (distanceFromBottom <= LANE_LOAD_THRESHOLD_PX) {
|
||||||
|
void loadMoreTickets(laneId);
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>Tickets · Yoi</title></svelte:head>
|
<svelte:head><title>Tickets · Yoi</title></svelte:head>
|
||||||
@@ -50,11 +138,7 @@
|
|||||||
<div class="workspace-page ticket-panel-page">
|
<div class="workspace-page ticket-panel-page">
|
||||||
<header class="workspace-page-header ticket-panel-header">
|
<header class="workspace-page-header ticket-panel-header">
|
||||||
<div>
|
<div>
|
||||||
<p class="workspace-eyebrow">Delivery</p>
|
|
||||||
<h1>Tickets</h1>
|
<h1>Tickets</h1>
|
||||||
<p class="workspace-page-lede">
|
|
||||||
Plan, route, review, and close work without leaving the workspace.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="ticket-panel-controls">
|
<div class="ticket-panel-controls">
|
||||||
<div class="orchestrator-status" data-online={orchestrator.data?.online ?? false}>
|
<div class="orchestrator-status" data-online={orchestrator.data?.online ?? false}>
|
||||||
@@ -75,8 +159,8 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="ticket-panel-summary" aria-label="Ticket summary">
|
<div class="ticket-panel-summary" aria-label="Ticket summary">
|
||||||
<strong>{tickets.length}</strong>
|
<strong>{displayedTicketCount}</strong>
|
||||||
<span>tickets</span>
|
<span>tickets loaded</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -99,10 +183,15 @@
|
|||||||
<span class="ticket-state-dot"></span>
|
<span class="ticket-state-dot"></span>
|
||||||
<h2>{lane.label}</h2>
|
<h2>{lane.label}</h2>
|
||||||
</div>
|
</div>
|
||||||
<span class="ticket-lane-count">{lane.tickets.length}</span>
|
<span class="ticket-lane-count">
|
||||||
|
{lane.tickets.length}{lane.hasMore ? "+" : ""}
|
||||||
|
</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="ticket-lane-cards">
|
<div
|
||||||
|
class="ticket-lane-cards"
|
||||||
|
onscroll={(event) => handleLaneScroll(event, lane.id)}
|
||||||
|
>
|
||||||
{#each lane.tickets as ticket (ticket.id)}
|
{#each lane.tickets as ticket (ticket.id)}
|
||||||
<a
|
<a
|
||||||
class="ticket-card"
|
class="ticket-card"
|
||||||
@@ -116,8 +205,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="ticket-lane-empty">No tickets</div>
|
{#if !lane.error}
|
||||||
|
<div class="ticket-lane-empty">No tickets</div>
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
|
{#if lane.loading}
|
||||||
|
<p class="ticket-lane-load-status" aria-live="polite">Loading 30 more…</p>
|
||||||
|
{:else if lane.error}
|
||||||
|
<div class="ticket-lane-load-error">
|
||||||
|
<small>{lane.error}</small>
|
||||||
|
<button
|
||||||
|
class="workspace-secondary-button"
|
||||||
|
type="button"
|
||||||
|
onclick={() => loadMoreTickets(lane.id)}
|
||||||
|
>Retry</button>
|
||||||
|
</div>
|
||||||
|
{:else if !lane.hasMore && lane.tickets.length > 0}
|
||||||
|
<p class="ticket-lane-load-status">All tickets loaded.</p>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -1,23 +1,63 @@
|
|||||||
|
import type { TicketQueryResponse } from "$lib/generated/ticket-api";
|
||||||
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
||||||
import type { WorkspaceOrchestratorStatus } from "$lib/workspace/tickets/ticket-panel";
|
import {
|
||||||
import type { TicketListResponse } from "$lib/workspace/sidebar/types";
|
ticketLaneDefinitions,
|
||||||
|
ticketLaneQuery,
|
||||||
|
ticketSummaryFromQueryItem,
|
||||||
|
type WorkspaceOrchestratorStatus,
|
||||||
|
} from "$lib/workspace/tickets/ticket-panel";
|
||||||
import type { PageLoad } from "./$types";
|
import type { PageLoad } from "./$types";
|
||||||
|
|
||||||
export const load = (async ({ fetch, params }) => {
|
export const load: PageLoad = async ({ fetch, params }) => {
|
||||||
const [tickets, orchestrator] = await Promise.all([
|
const workspaceId = params.workspaceId;
|
||||||
loadJson<TicketListResponse>(
|
const ticketLanePagesPromise = Promise.all(
|
||||||
fetch,
|
ticketLaneDefinitions().map(async (lane) => {
|
||||||
`${workspaceApiPath(params.workspaceId, "/tickets")}?limit=1000`,
|
try {
|
||||||
),
|
const page = await loadJson<TicketQueryResponse>(
|
||||||
|
fetch,
|
||||||
|
workspaceApiPath(workspaceId, "/tickets/query"),
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(ticketLaneQuery(lane)),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
id: lane.id,
|
||||||
|
label: lane.label,
|
||||||
|
states: [...lane.states],
|
||||||
|
tickets: page.data?.items.map(ticketSummaryFromQueryItem) ?? [],
|
||||||
|
nextCursor: page.data?.page.next_cursor ?? null,
|
||||||
|
hasMore: page.data?.page.has_more ?? false,
|
||||||
|
error: page.error,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
id: lane.id,
|
||||||
|
label: lane.label,
|
||||||
|
states: [...lane.states],
|
||||||
|
tickets: [],
|
||||||
|
nextCursor: null,
|
||||||
|
hasMore: false,
|
||||||
|
error: error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Unable to load Tickets.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const [ticketLanePages, orchestrator] = await Promise.all([
|
||||||
|
ticketLanePagesPromise,
|
||||||
loadJson<WorkspaceOrchestratorStatus>(
|
loadJson<WorkspaceOrchestratorStatus>(
|
||||||
fetch,
|
fetch,
|
||||||
workspaceApiPath(params.workspaceId, "/orchestrator"),
|
workspaceApiPath(workspaceId, "/orchestrator"),
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
workspaceId: params.workspaceId,
|
workspaceId,
|
||||||
tickets,
|
ticketLanePages,
|
||||||
orchestrator,
|
orchestrator,
|
||||||
};
|
};
|
||||||
}) satisfies PageLoad;
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user