feat: paginate Ticket board lanes independently
This commit is contained in:
@@ -169,8 +169,32 @@
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 0.55rem;
|
||||
max-height: min(68vh, 48rem);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 0.6rem;
|
||||
}
|
||||
.ticket-lane-page-state {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
padding: 0.45rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.72rem;
|
||||
text-align: center;
|
||||
}
|
||||
.ticket-lane-page-error {
|
||||
align-items: center;
|
||||
color: var(--danger);
|
||||
}
|
||||
.ticket-lane-page-error button {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.35rem;
|
||||
background: var(--bg-raised);
|
||||
color: inherit;
|
||||
padding: 0.2rem 0.45rem;
|
||||
}
|
||||
.ticket-card {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
|
||||
@@ -105,6 +105,32 @@ Deno.test("ticket worker launch uses the common Worker route and bounded Ticket
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("ticket board paginates every lane independently with bounded summary requests", async () => {
|
||||
const loadSource = await Deno.readTextFile(
|
||||
"src/routes/w/[workspaceId]/tickets/+page.ts",
|
||||
);
|
||||
const pageSource = await Deno.readTextFile(
|
||||
"src/routes/w/[workspaceId]/tickets/+page.svelte",
|
||||
);
|
||||
|
||||
assertIncludes(loadSource, 'limit: "30"');
|
||||
assertIncludes(loadSource, "states: states.join");
|
||||
assertIncludes(pageSource, "lane.page.next_cursor");
|
||||
assertIncludes(pageSource, "lane.loading");
|
||||
assertIncludes(pageSource, "mergeTickets");
|
||||
assertIncludes(pageSource, "onscroll");
|
||||
assertIncludes(pageSource, "Retry");
|
||||
if (loadSource.includes("limit=1000") || pageSource.includes("limit=1000")) {
|
||||
throw new Error("Ticket board must not fetch the legacy 1000-item list");
|
||||
}
|
||||
if (
|
||||
loadSource.includes("/tickets/query") ||
|
||||
pageSource.includes("/tickets/query")
|
||||
) {
|
||||
throw new Error("Ticket board must use the bounded summary endpoint");
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("ticket panel starts the Orchestrator explicitly and gates orchestration actions", async () => {
|
||||
const panelSource = await Deno.readTextFile(
|
||||
"src/routes/w/[workspaceId]/tickets/+page.svelte",
|
||||
@@ -113,7 +139,10 @@ Deno.test("ticket panel starts the Orchestrator explicitly and gates orchestrati
|
||||
"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, "Start Orchestrator");
|
||||
assertIncludes(panelSource, "orchestrator.data?.online");
|
||||
|
||||
@@ -2,30 +2,94 @@
|
||||
import { untrack } from "svelte";
|
||||
import type { ApiResult } from "$lib/workspace/api/http";
|
||||
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
||||
import type {
|
||||
QueryPage,
|
||||
TicketListResponse,
|
||||
TicketSummary,
|
||||
} from "$lib/generated/ticket-api";
|
||||
import {
|
||||
ticketLanes,
|
||||
type WorkspaceOrchestratorStatus,
|
||||
} from "$lib/workspace/tickets/ticket-panel";
|
||||
import type {
|
||||
TicketListResponse,
|
||||
TicketSummary,
|
||||
} from "$lib/workspace/sidebar/types";
|
||||
import type { PageData } from "./$types";
|
||||
|
||||
const { data } = $props<{
|
||||
data: {
|
||||
workspaceId: string;
|
||||
tickets: ApiResult<TicketListResponse>;
|
||||
orchestrator: ApiResult<WorkspaceOrchestratorStatus>;
|
||||
};
|
||||
}>();
|
||||
type LaneState = {
|
||||
states: string[];
|
||||
tickets: TicketSummary[];
|
||||
page: QueryPage;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
const initialTickets = untrack(() => data.tickets.data?.items ?? []);
|
||||
let tickets = $state<TicketSummary[]>(initialTickets);
|
||||
let { data }: { data: PageData } = $props();
|
||||
// svelte-ignore state_referenced_locally
|
||||
let laneState = $state<Record<string, LaneState>>(
|
||||
Object.fromEntries(
|
||||
Object.entries(data.ticketLanes).map(([laneId, lane]) => [
|
||||
laneId,
|
||||
{
|
||||
states: [...lane.states],
|
||||
tickets: lane.response.items,
|
||||
page: lane.response.page,
|
||||
loading: false,
|
||||
error: null,
|
||||
},
|
||||
]),
|
||||
),
|
||||
);
|
||||
let orchestrator = $state<ApiResult<WorkspaceOrchestratorStatus>>(
|
||||
untrack(() => data.orchestrator),
|
||||
);
|
||||
let orchestratorStarting = $state(false);
|
||||
let lanes = $derived(ticketLanes(tickets));
|
||||
const tickets = $derived(
|
||||
Object.values(laneState).flatMap((lane) => lane.tickets),
|
||||
);
|
||||
const lanes = $derived(ticketLanes(tickets));
|
||||
|
||||
function mergeTickets(
|
||||
current: TicketSummary[],
|
||||
incoming: TicketSummary[],
|
||||
): TicketSummary[] {
|
||||
const byId = new Map(current.map((ticket) => [ticket.id, ticket]));
|
||||
for (const ticket of incoming) byId.set(ticket.id, ticket);
|
||||
return [...byId.values()];
|
||||
}
|
||||
|
||||
async function loadMore(laneId: string): Promise<void> {
|
||||
const lane = laneState[laneId];
|
||||
if (!lane || lane.loading || !lane.page.has_more || !lane.page.next_cursor) {
|
||||
return;
|
||||
}
|
||||
lane.loading = true;
|
||||
lane.error = null;
|
||||
try {
|
||||
const search = new URLSearchParams({
|
||||
limit: "30",
|
||||
states: lane.states.join(","),
|
||||
cursor: lane.page.next_cursor,
|
||||
});
|
||||
const response = await fetch(
|
||||
`/api/w/${encodeURIComponent(data.workspaceId)}/tickets?${search}`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`追加読み込みに失敗しました (${response.status})`);
|
||||
}
|
||||
const page = (await response.json()) as TicketListResponse;
|
||||
lane.tickets = mergeTickets(lane.tickets, page.items);
|
||||
lane.page = page.page;
|
||||
} catch (error) {
|
||||
lane.error = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
lane.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleLaneScroll(event: Event, laneId: string): void {
|
||||
const container = event.currentTarget as HTMLElement;
|
||||
const remaining =
|
||||
container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||
if (remaining <= 96) void loadMore(laneId);
|
||||
}
|
||||
|
||||
async function startOrchestrator() {
|
||||
if (orchestratorStarting || orchestrator.data?.online) return;
|
||||
@@ -45,7 +109,9 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Tickets · Yoi</title></svelte:head>
|
||||
<svelte:head>
|
||||
<title>Tickets · {data.workspaceId}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="workspace-page ticket-panel-page">
|
||||
<header class="workspace-page-header ticket-panel-header">
|
||||
@@ -76,7 +142,7 @@
|
||||
</div>
|
||||
<div class="ticket-panel-summary" aria-label="Ticket summary">
|
||||
<strong>{tickets.length}</strong>
|
||||
<span>tickets</span>
|
||||
<span>loaded tickets</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -93,6 +159,7 @@
|
||||
|
||||
<section class="ticket-kanban" aria-label="Ticket workflow board">
|
||||
{#each lanes as lane (lane.id)}
|
||||
{@const pagination = laneState[lane.id]}
|
||||
<section class="ticket-lane" data-state={lane.id}>
|
||||
<header class="ticket-lane-header">
|
||||
<div>
|
||||
@@ -101,8 +168,11 @@
|
||||
</div>
|
||||
<span class="ticket-lane-count">{lane.tickets.length}</span>
|
||||
</header>
|
||||
|
||||
<div class="ticket-lane-cards">
|
||||
<div
|
||||
class="ticket-lane-cards"
|
||||
data-lane-id={lane.id}
|
||||
onscroll={(event) => handleLaneScroll(event, lane.id)}
|
||||
>
|
||||
{#each lane.tickets as ticket (ticket.id)}
|
||||
<a
|
||||
class="ticket-card"
|
||||
@@ -118,6 +188,16 @@
|
||||
{:else}
|
||||
<div class="ticket-lane-empty">No tickets</div>
|
||||
{/each}
|
||||
{#if pagination?.loading}
|
||||
<p class="ticket-lane-page-state" aria-live="polite">Loading…</p>
|
||||
{:else if pagination?.error}
|
||||
<div class="ticket-lane-page-state ticket-lane-page-error" role="alert">
|
||||
<span>{pagination.error}</span>
|
||||
<button type="button" onclick={() => loadMore(lane.id)}>Retry</button>
|
||||
</div>
|
||||
{:else if pagination && !pagination.page.has_more && lane.tickets.length > 0}
|
||||
<p class="ticket-lane-page-state">End of lane</p>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
{/each}
|
||||
|
||||
@@ -1,23 +1,56 @@
|
||||
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
||||
import type { TicketListResponse } from "$lib/generated/ticket-api";
|
||||
import type { WorkspaceOrchestratorStatus } from "$lib/workspace/tickets/ticket-panel";
|
||||
import type { TicketListResponse } from "$lib/workspace/sidebar/types";
|
||||
import type { PageLoad } from "./$types";
|
||||
|
||||
export const load = (async ({ fetch, params }) => {
|
||||
const [tickets, orchestrator] = await Promise.all([
|
||||
loadJson<TicketListResponse>(
|
||||
fetch,
|
||||
`${workspaceApiPath(params.workspaceId, "/tickets")}?limit=1000`,
|
||||
const LANE_STATES = {
|
||||
"ready-planning": ["ready", "planning"],
|
||||
"inprogress-queued": ["inprogress", "queued"],
|
||||
"done-closed": ["done", "closed"],
|
||||
} as const;
|
||||
|
||||
export type TicketLaneId = keyof typeof LANE_STATES;
|
||||
|
||||
export type TicketLanePage = {
|
||||
states: readonly string[];
|
||||
response: TicketListResponse;
|
||||
};
|
||||
|
||||
export const load: PageLoad = async ({ fetch, params }) => {
|
||||
const workspaceId = params.workspaceId;
|
||||
const [entries, orchestrator] = await Promise.all([
|
||||
Promise.all(
|
||||
Object.entries(LANE_STATES).map(async ([laneId, states]) => {
|
||||
const search = new URLSearchParams({
|
||||
limit: "30",
|
||||
states: states.join(","),
|
||||
});
|
||||
const response = await fetch(
|
||||
`/api/w/${encodeURIComponent(workspaceId)}/tickets?${search}`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`failed to load ${laneId} Ticket lane (${response.status})`,
|
||||
);
|
||||
}
|
||||
return [
|
||||
laneId,
|
||||
{ states: [...states], response: await response.json() },
|
||||
] as const;
|
||||
}),
|
||||
),
|
||||
loadJson<WorkspaceOrchestratorStatus>(
|
||||
fetch,
|
||||
workspaceApiPath(params.workspaceId, "/orchestrator"),
|
||||
workspaceApiPath(workspaceId, "/orchestrator"),
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
workspaceId: params.workspaceId,
|
||||
tickets,
|
||||
workspaceId,
|
||||
ticketLanes: Object.fromEntries(entries) as unknown as Record<
|
||||
TicketLaneId,
|
||||
TicketLanePage
|
||||
>,
|
||||
orchestrator,
|
||||
};
|
||||
}) satisfies PageLoad;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user