feat: replace Ticket kanban with query-based list
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
"dev": "deno run -A npm:vite@8.3.2 dev",
|
||||
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
|
||||
"check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json",
|
||||
"test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts",
|
||||
"test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts src/lib/workspace/tickets/ticket-search.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts",
|
||||
"test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts",
|
||||
"test:browser:empty-workspace": "deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/visual-review/empty-workspace.browser.test.ts",
|
||||
"test:browser:drive": "deno test --config test/drive-browser/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/drive-browser/drive.browser.test.ts",
|
||||
|
||||
@@ -319,7 +319,7 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async ()
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", async () => {
|
||||
Deno.test("workspace Tickets surface provides query list and lifecycle controls", async () => {
|
||||
const ticketsNav = await Deno.readTextFile(
|
||||
new URL("../sidebar/TicketsNavSection.svelte", import.meta.url),
|
||||
);
|
||||
@@ -353,9 +353,6 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const ticketPanelModel = await Deno.readTextFile(
|
||||
new URL("../tickets/ticket-panel.ts", import.meta.url),
|
||||
);
|
||||
const generatedTicketApi = await Deno.readTextFile(
|
||||
new URL("../../generated/ticket-api.ts", import.meta.url),
|
||||
);
|
||||
@@ -391,21 +388,14 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as
|
||||
!mergeRequestsNav.includes("review and integration resources"),
|
||||
"Tickets, Objectives, and Merge Requests should each be a single primary sidebar link",
|
||||
);
|
||||
// クエリ・ページングの振舞いはticket-search.testとTicket browser suiteで検証する。
|
||||
assert(
|
||||
ticketsLoad.includes("ticketQueryRequest(filters, [state])") &&
|
||||
ticketsLoad.includes("compileTicketSearch") &&
|
||||
ticketsLoad.includes("/tickets/query") &&
|
||||
ticketsLoad.includes('method: "POST"') &&
|
||||
ticketsPage.includes('class="ticket-kanban"') &&
|
||||
ticketsPage.includes("laneState") &&
|
||||
ticketsPage.includes("loadMore(lane.id)"),
|
||||
"Tickets list must apply shared server query filters before paging case lanes",
|
||||
);
|
||||
assert(
|
||||
ticketPanelModel.includes('label: "Open"') &&
|
||||
ticketPanelModel.includes('label: "Closed"') &&
|
||||
ticketPanelModel.includes("TICKET_LANE_PAGE_SIZE = 30") &&
|
||||
ticketPanelModel.includes("nextTicketLaneVisibleCount"),
|
||||
"Ticket Kanban should separate cases into independent 30-item display windows",
|
||||
ticketsPage.includes('class="ticket-list"') &&
|
||||
!ticketsPage.includes("TicketQueryFilters") &&
|
||||
!ticketsPage.includes("ticket-kanban"),
|
||||
"Tickets use one server query and a flat list rather than lanes or separate filters",
|
||||
);
|
||||
assert(
|
||||
generatedTicketApi.includes(
|
||||
|
||||
@@ -62,9 +62,6 @@
|
||||
.ticket-detail-kicker,
|
||||
.ticket-section-heading,
|
||||
.ticket-control-card > header,
|
||||
.ticket-lane-header,
|
||||
.ticket-lane-header > div,
|
||||
.ticket-card-meta,
|
||||
.ticket-role-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -118,160 +115,6 @@
|
||||
.orchestrator-status[data-online="true"] .orchestrator-status-dot {
|
||||
background: var(--success);
|
||||
}
|
||||
.ticket-panel-summary {
|
||||
display: grid;
|
||||
justify-items: end;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.ticket-panel-summary strong {
|
||||
color: var(--text-strong);
|
||||
font-size: var(--font-size-compact);
|
||||
line-height: var(--line-height-compact);
|
||||
}
|
||||
.ticket-kanban {
|
||||
display: grid;
|
||||
align-items: stretch;
|
||||
grid-template-columns: repeat(2, minmax(18rem, 1fr));
|
||||
gap: var(--space-3);
|
||||
overflow-x: auto;
|
||||
padding-bottom: var(--space-3);
|
||||
}
|
||||
.ticket-lane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100dvh - 15rem);
|
||||
min-height: 24rem;
|
||||
max-height: 48rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.8rem;
|
||||
background: color-mix(in srgb, var(--bg-raised) 55%, transparent);
|
||||
}
|
||||
.ticket-lane-header {
|
||||
flex: 0 0 auto;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.ticket-lane-header > div {
|
||||
gap: 0.45rem;
|
||||
}
|
||||
.ticket-lane-header h2 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-compact);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.ticket-state-dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
}
|
||||
.ticket-lane[data-state="open"] .ticket-state-dot {
|
||||
background: var(--success);
|
||||
}
|
||||
.ticket-lane[data-state="closed"] .ticket-state-dot {
|
||||
background: var(--accent);
|
||||
}
|
||||
.ticket-lane-count {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-compact);
|
||||
}
|
||||
.ticket-lane-cards {
|
||||
display: grid;
|
||||
flex: 1 1 auto;
|
||||
align-content: start;
|
||||
gap: 0.55rem;
|
||||
min-height: 0;
|
||||
max-height: min(68vh, 48rem);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 0.6rem;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
.ticket-lane-load-status {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
padding: 0.5rem;
|
||||
font-size: var(--font-size-compact);
|
||||
text-align: center;
|
||||
}
|
||||
.ticket-lane-page-state {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
padding: 0.45rem;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-compact);
|
||||
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;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.65rem;
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-strong);
|
||||
padding: 0.75rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
.ticket-card:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.ticket-card-id {
|
||||
overflow: hidden;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-compact);
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.ticket-card strong {
|
||||
font-size: var(--font-size-compact);
|
||||
line-height: 1.35;
|
||||
}
|
||||
.ticket-card-meta {
|
||||
justify-content: space-between;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-compact);
|
||||
}
|
||||
.ticket-risk-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.ticket-risk-list span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
color: var(--warning);
|
||||
font-size: var(--font-size-compact);
|
||||
}
|
||||
.ticket-risk-list span::before {
|
||||
width: 0.4rem;
|
||||
height: 0.4rem;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
content: "";
|
||||
}
|
||||
.ticket-lane-empty {
|
||||
padding: 1.2rem 0.4rem;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-compact);
|
||||
text-align: center;
|
||||
}
|
||||
.ticket-detail-page {
|
||||
container: ticket-detail / inline-size;
|
||||
gap: var(--space-4);
|
||||
|
||||
@@ -3,18 +3,12 @@ import {
|
||||
currentOrchestratorAssignment,
|
||||
handlingLabel,
|
||||
handlingSummary,
|
||||
nextTicketLaneVisibleCount,
|
||||
readTicketFilters,
|
||||
TICKET_LANE_PAGE_SIZE,
|
||||
ticketLanes,
|
||||
ticketQueryRequest,
|
||||
ticketWorkerLaunchHref,
|
||||
ticketWorkerMessage,
|
||||
} from "./ticket-panel.ts";
|
||||
import type {
|
||||
TicketDetail,
|
||||
TicketListItemSummary as TicketSummary,
|
||||
} from "../../generated/ticket-api.ts";
|
||||
import type { TicketDetail } from "../../generated/ticket-api.ts";
|
||||
|
||||
declare const Deno: {
|
||||
test(name: string, fn: () => void | Promise<void>): void;
|
||||
@@ -29,57 +23,6 @@ function assertEquals<T>(actual: T, expected: T): void {
|
||||
}
|
||||
}
|
||||
|
||||
Deno.test("ticketLanes separates open and closed cases and orders updates deterministically", () => {
|
||||
const tickets = [
|
||||
{
|
||||
id: "old",
|
||||
state: "open",
|
||||
title: "Open old",
|
||||
tags: [{
|
||||
workspace_id: "workspace-a",
|
||||
id: "tag-closed",
|
||||
name: "closed",
|
||||
last_operation_key: "create-closed",
|
||||
deleted: false,
|
||||
}],
|
||||
updated_at: "2026-07-29T12:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "new",
|
||||
state: "open",
|
||||
title: "Open new",
|
||||
tags: [],
|
||||
updated_at: "2026-07-30T12:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "closed",
|
||||
state: "closed",
|
||||
title: "Closed",
|
||||
tags: [{
|
||||
workspace_id: "workspace-a",
|
||||
id: "tag-ready",
|
||||
name: "ready",
|
||||
last_operation_key: "create-ready",
|
||||
deleted: false,
|
||||
}],
|
||||
updated_at: null,
|
||||
},
|
||||
] as TicketSummary[];
|
||||
const lanes = ticketLanes(tickets);
|
||||
assertEquals(lanes.map((lane) => lane.id), ["open", "closed"]);
|
||||
assertEquals(lanes[0].tickets.map((ticket) => ticket.id), ["new", "old"]);
|
||||
assertEquals(lanes[1].tickets.map((ticket) => ticket.id), ["closed"]);
|
||||
});
|
||||
|
||||
Deno.test("ticket lane visibility advances in bounded pages of 30", () => {
|
||||
assertEquals(TICKET_LANE_PAGE_SIZE, 30);
|
||||
assertEquals(nextTicketLaneVisibleCount(0, 95), 30);
|
||||
assertEquals(nextTicketLaneVisibleCount(30, 95), 60);
|
||||
assertEquals(nextTicketLaneVisibleCount(60, 95), 90);
|
||||
assertEquals(nextTicketLaneVisibleCount(90, 95), 95);
|
||||
assertEquals(nextTicketLaneVisibleCount(95, 95), 95);
|
||||
});
|
||||
|
||||
Deno.test("generic Ticket launch carries natural text without role or resource claims", () => {
|
||||
const requests = [
|
||||
"Review is unnecessary; complete the requested work.",
|
||||
|
||||
@@ -5,7 +5,6 @@ import type {
|
||||
TicketAssignmentTrackingStatus,
|
||||
TicketClosureReason,
|
||||
TicketDetail,
|
||||
TicketListItemSummary,
|
||||
TicketQueryItem,
|
||||
TicketQueryRequest,
|
||||
} from "#lib/generated/ticket-api.ts";
|
||||
@@ -13,65 +12,6 @@ import type {
|
||||
export const TICKET_STATES = ["open", "closed"] as const;
|
||||
export type TicketState = (typeof TICKET_STATES)[number];
|
||||
export type WorkspaceOrchestratorStatus = BrowserWorkspaceOrchestratorResponse;
|
||||
const LANE_DEFINITIONS = [
|
||||
{ id: "open", label: "Open", states: ["open"] },
|
||||
{ id: "closed", label: "Closed", states: ["closed"] },
|
||||
] 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<
|
||||
TicketListItemSummary,
|
||||
"id" | "resource_key" | "title" | "state" | "priority" | "updated_at"
|
||||
>
|
||||
& Partial<
|
||||
Pick<TicketQueryItem, "tags" | "current_closure" | "assignment_tracking">
|
||||
>;
|
||||
|
||||
export type TicketLane = {
|
||||
id: TicketLaneId;
|
||||
label: string;
|
||||
states: readonly TicketState[];
|
||||
tickets: TicketCardSummary[];
|
||||
};
|
||||
|
||||
export function nextTicketLaneVisibleCount(
|
||||
current: number,
|
||||
total: number,
|
||||
): number {
|
||||
return Math.min(total, current + TICKET_LANE_PAGE_SIZE);
|
||||
}
|
||||
|
||||
function updatedAt(ticket: TicketCardSummary): number {
|
||||
if (!ticket.updated_at) return 0;
|
||||
const parsed = Date.parse(ticket.updated_at);
|
||||
return Number.isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
|
||||
export function sortTickets(
|
||||
tickets: TicketCardSummary[],
|
||||
): TicketCardSummary[] {
|
||||
return [...tickets].sort((left, right) => {
|
||||
const updatedDelta = updatedAt(right) - updatedAt(left);
|
||||
if (updatedDelta !== 0) return updatedDelta;
|
||||
return left.id.localeCompare(right.id);
|
||||
});
|
||||
}
|
||||
|
||||
export function ticketLanes(tickets: TicketCardSummary[]): TicketLane[] {
|
||||
return LANE_DEFINITIONS.map((definition) => ({
|
||||
...definition,
|
||||
tickets: sortTickets(
|
||||
tickets.filter((ticket) =>
|
||||
(definition.states as readonly string[]).includes(ticket.state)
|
||||
),
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
export function ticketWorkerMessage(ticketId: string): string {
|
||||
return `Work on Ticket ${ticketId}.`;
|
||||
}
|
||||
@@ -228,7 +168,7 @@ export function ticketQueryRequest(
|
||||
assigned_principal: filterPrincipal(filters),
|
||||
handling_status: handlingStatus,
|
||||
sort: "updated_desc",
|
||||
limit: TICKET_LANE_PAGE_SIZE as TicketQueryRequest["limit"],
|
||||
limit: 30 as TicketQueryRequest["limit"],
|
||||
cursor,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
import {
|
||||
compileTicketSearch,
|
||||
DEFAULT_TICKET_SEARCH,
|
||||
ticketSearchNeedsTags,
|
||||
} from "./ticket-search.ts";
|
||||
import type { WorkspaceTag } from "../../generated/ticket-api.ts";
|
||||
|
||||
declare const Deno: {
|
||||
test(name: string, fn: () => void): void;
|
||||
};
|
||||
|
||||
function assertEquals<T>(actual: T, expected: T): void {
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||
throw new Error(
|
||||
`expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertSearchError(
|
||||
input: string,
|
||||
messagePart: string,
|
||||
availableTags = tags,
|
||||
): void {
|
||||
try {
|
||||
compileTicketSearch(input, availableTags);
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error) || !error.message.includes(messagePart)) {
|
||||
throw new Error(
|
||||
`expected error containing ${JSON.stringify(messagePart)}, got ${
|
||||
String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
throw new Error(`expected ${JSON.stringify(input)} to fail`);
|
||||
}
|
||||
|
||||
const tags: readonly WorkspaceTag[] = Object.freeze([
|
||||
Object.freeze({
|
||||
workspace_id: "workspace",
|
||||
id: "tag-1",
|
||||
name: "Ready",
|
||||
last_operation_key: "create-1",
|
||||
deleted: false,
|
||||
}),
|
||||
Object.freeze({
|
||||
workspace_id: "workspace",
|
||||
id: "tag-2",
|
||||
name: "Needs review",
|
||||
last_operation_key: "create-2",
|
||||
deleted: false,
|
||||
}),
|
||||
Object.freeze({
|
||||
workspace_id: "workspace",
|
||||
id: "tag-3",
|
||||
name: "Retired",
|
||||
last_operation_key: "delete-3",
|
||||
deleted: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
Deno.test("only actual tag qualifiers require the tag catalog", () => {
|
||||
for (
|
||||
const input of [
|
||||
"",
|
||||
"state:open",
|
||||
'"text tag:Ready"',
|
||||
'reason:"text tag:Ready"',
|
||||
"TAG:Ready",
|
||||
"https://example.test/tag:Ready",
|
||||
]
|
||||
) {
|
||||
assertEquals(ticketSearchNeedsTags(input), false);
|
||||
}
|
||||
for (const input of ['tag:"Needs review"', "text tag:Ready", "tag:"]) {
|
||||
assertEquals(ticketSearchNeedsTags(input), true);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("empty search selects all states with bounded first-page defaults", () => {
|
||||
for (const input of ["", " \t\n "]) {
|
||||
assertEquals<unknown>(compileTicketSearch(input, tags), {
|
||||
query: null,
|
||||
states: [],
|
||||
tag_ids: [],
|
||||
assigned_principal: null,
|
||||
handling_status: null,
|
||||
sort: "updated_desc",
|
||||
limit: 30,
|
||||
cursor: null,
|
||||
});
|
||||
}
|
||||
assertEquals(DEFAULT_TICKET_SEARCH, "state:open");
|
||||
assertEquals(compileTicketSearch(DEFAULT_TICKET_SEARCH, tags).states, [
|
||||
"open",
|
||||
]);
|
||||
});
|
||||
|
||||
Deno.test("plain words preserve case and do not implicitly restrict states", () => {
|
||||
const request = compileTicketSearch(" Fix\tHTTP Cache\nBug ", tags);
|
||||
assertEquals(request.query, "Fix HTTP Cache Bug");
|
||||
assertEquals(request.states, []);
|
||||
});
|
||||
|
||||
Deno.test("state filters never leak into backend free text", () => {
|
||||
for (
|
||||
const [value, expected] of [["open", ["open"]], ["closed", ["closed"]], [
|
||||
"all",
|
||||
[],
|
||||
]] as const
|
||||
) {
|
||||
const request = compileTicketSearch(`Fix state:${value} HTTP`, tags);
|
||||
assertEquals(request.states, [...expected]);
|
||||
assertEquals(request.query, "Fix HTTP");
|
||||
assertEquals(compileTicketSearch(`state:${value}`, tags).query, null);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("unknown qualifiers and URLs remain ordinary free text", () => {
|
||||
const input =
|
||||
'https://Example.test/state:closed label:Ready handling:done sort:oldest close:yes field:"Some Text"';
|
||||
const request = compileTicketSearch(input, tags);
|
||||
assertEquals(request.query, input);
|
||||
assertEquals(request.states, []);
|
||||
assertEquals(request.tag_ids, []);
|
||||
assertEquals(request.handling_status, null);
|
||||
assertEquals(request.sort, "updated_desc");
|
||||
});
|
||||
|
||||
Deno.test("quoted ordinary text stays text while quoted qualifier values are decoded", () => {
|
||||
const request = compileTicketSearch(
|
||||
'"state:closed" "HTTP Cache" tag:"Needs review" state:"open"',
|
||||
tags,
|
||||
);
|
||||
assertEquals(request.query, "state:closed HTTP Cache");
|
||||
assertEquals(request.states, ["open"]);
|
||||
assertEquals(request.tag_ids, ["tag-2"]);
|
||||
assertEquals(
|
||||
compileTicketSearch("Don't lose Case", tags).query,
|
||||
"Don't lose Case",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("active tag names and stable IDs resolve canonically and deduplicate without mutation", () => {
|
||||
const request = compileTicketSearch(
|
||||
'tag:Ready tag:tag-1 tag:"Needs review" tag:tag-2 tag:Ready',
|
||||
tags,
|
||||
);
|
||||
assertEquals(request.tag_ids, ["tag-1", "tag-2"]);
|
||||
assertEquals(request.query, null);
|
||||
request.tag_ids?.push("not-in-catalog");
|
||||
assertEquals(compileTicketSearch("tag:Ready", tags).tag_ids, ["tag-1"]);
|
||||
assertEquals(tags[0].name, "Ready");
|
||||
});
|
||||
|
||||
Deno.test("unknown deleted and case-mismatched tags fail instead of broadening search", () => {
|
||||
for (const value of ["Missing", "Retired", "tag-3", "ready"]) {
|
||||
assertSearchError(`tag:${value}`, "tag");
|
||||
}
|
||||
assertSearchError("tag:Ready", "active tag", []);
|
||||
});
|
||||
|
||||
Deno.test("ambiguous tag names require a stable ID and stable IDs take precedence", () => {
|
||||
const catalog = [...tags, { ...tags[0], id: "tag-4" }, {
|
||||
...tags[1],
|
||||
name: "tag-1",
|
||||
}];
|
||||
assertSearchError("tag:Ready", "stable tag ID", catalog);
|
||||
assertEquals(compileTicketSearch("tag:tag-1", catalog).tag_ids, ["tag-1"]);
|
||||
});
|
||||
|
||||
Deno.test("assignee forms compile to generated principal snapshots without altering case", () => {
|
||||
for (
|
||||
const [input, expected] of [
|
||||
["orchestrator", {
|
||||
kind: "workspace_agent",
|
||||
agent_key: "workspace-orchestrator",
|
||||
}],
|
||||
["user/Account-A", { kind: "user", account_id: "Account-A" }],
|
||||
["worker/Runtime-A/Worker-B", {
|
||||
kind: "worker",
|
||||
runtime_id: "Runtime-A",
|
||||
worker_id: "Worker-B",
|
||||
}],
|
||||
] as const
|
||||
) {
|
||||
const request = compileTicketSearch(`assignee:${input}`, tags);
|
||||
assertEquals(request.assigned_principal, expected);
|
||||
assertEquals(request.query, null);
|
||||
assertEquals(request.states, []);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("malformed assignees reject missing extra or whitespace-separated ID components", () => {
|
||||
for (
|
||||
const input of [
|
||||
"user",
|
||||
"user/",
|
||||
"user/a/b",
|
||||
"worker",
|
||||
"worker/r",
|
||||
"worker//w",
|
||||
"worker/r/",
|
||||
"worker/r/w/extra",
|
||||
"orchestrator/extra",
|
||||
"workspace-orchestrator",
|
||||
'"user/Account A"',
|
||||
'"worker/ /w"',
|
||||
]
|
||||
) {
|
||||
assertSearchError(`assignee:${input}`, "user/<account-id>");
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("built-in reasons remain built-in even when quoted and do not imply closed state", () => {
|
||||
for (
|
||||
const value of ["completed", "not_planned", "duplicate", "unknown"] as const
|
||||
) {
|
||||
for (const input of [`reason:${value}`, `reason:"${value}"`]) {
|
||||
const request = compileTicketSearch(input, tags);
|
||||
assertEquals(request.closure_reason, value);
|
||||
assertEquals(request.states, []);
|
||||
assertEquals(request.query, null);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("custom reasons require quotes and preserve exact text", () => {
|
||||
for (
|
||||
const value of ["Superseded Plan", "Completed", "other", " Exact text "]
|
||||
) {
|
||||
assertEquals(
|
||||
compileTicketSearch(`reason:"${value}"`, tags).closure_reason,
|
||||
{ other: value },
|
||||
);
|
||||
}
|
||||
assertSearchError("reason:Superseded", "double-quote custom exact text");
|
||||
assertSearchError("reason:other", "double-quote custom exact text");
|
||||
});
|
||||
|
||||
Deno.test("recognized qualifiers reject empty values immediately after the colon", () => {
|
||||
for (const qualifier of ["state", "tag", "assignee", "reason"]) {
|
||||
for (const suffix of ["", '""', '" "', " word"]) {
|
||||
assertSearchError(`${qualifier}:${suffix}`, `Empty ${qualifier}:`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("state close and other unrecognized state values are not aliases or text", () => {
|
||||
for (const value of ["close", "Closed", "OPEN", "done", "*", "open,closed"]) {
|
||||
assertSearchError(`state:${value}`, "use open, closed, or all");
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("conflicting singleton filters fail rather than applying the last value", () => {
|
||||
for (
|
||||
const [input, qualifier] of [
|
||||
["state:open state:closed", "state"],
|
||||
["state:all state:open", "state"],
|
||||
["assignee:user/a assignee:user/b", "assignee"],
|
||||
["assignee:orchestrator assignee:worker/r/w", "assignee"],
|
||||
["reason:completed reason:duplicate", "reason"],
|
||||
['reason:"Custom" reason:"custom"', "reason"],
|
||||
]
|
||||
) {
|
||||
assertSearchError(input, `Conflicting ${qualifier}:`);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("equivalent singleton filters may repeat without changing their meaning", () => {
|
||||
const request = compileTicketSearch(
|
||||
'state:closed state:"closed" assignee:user/a assignee:"user/a" reason:completed reason:"completed"',
|
||||
tags,
|
||||
);
|
||||
assertEquals(request.states, ["closed"]);
|
||||
assertEquals(request.assigned_principal, { kind: "user", account_id: "a" });
|
||||
assertEquals(request.closure_reason, "completed");
|
||||
});
|
||||
|
||||
Deno.test("unterminated quotes report an actionable error in filters and ordinary text", () => {
|
||||
for (
|
||||
const input of [
|
||||
'tag:"Needs review',
|
||||
'reason:"Custom',
|
||||
'"ordinary text',
|
||||
'url:"unfinished',
|
||||
]
|
||||
) {
|
||||
assertSearchError(input, "add a closing double quote");
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("malformed quoted qualifier values fail rather than concatenating fragments", () => {
|
||||
for (
|
||||
const input of [
|
||||
'state:"open"extra',
|
||||
'tag:Ready"oops"',
|
||||
'reason:"custom""text"',
|
||||
]
|
||||
) {
|
||||
assertSearchError(input, "one pair of double quotes");
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("mixed search compiles every supported filter and only remaining words", () => {
|
||||
assertEquals<unknown>(
|
||||
compileTicketSearch(
|
||||
'Fix state:closed tag:Ready HTTP assignee:worker/Runtime/Worker reason:"Superseded plan"',
|
||||
tags,
|
||||
),
|
||||
{
|
||||
query: "Fix HTTP",
|
||||
states: ["closed"],
|
||||
tag_ids: ["tag-1"],
|
||||
assigned_principal: {
|
||||
kind: "worker",
|
||||
runtime_id: "Runtime",
|
||||
worker_id: "Worker",
|
||||
},
|
||||
handling_status: null,
|
||||
sort: "updated_desc",
|
||||
limit: 30,
|
||||
cursor: null,
|
||||
closure_reason: { other: "Superseded plan" },
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import type {
|
||||
TicketAssignmentPrincipalSnapshot,
|
||||
TicketClosureReason,
|
||||
TicketQueryRequest,
|
||||
WorkspaceTag,
|
||||
} from "../../generated/ticket-api.ts";
|
||||
|
||||
export const DEFAULT_TICKET_SEARCH = "state:open";
|
||||
|
||||
function tokens(input: string): string[] {
|
||||
const result: string[] = [];
|
||||
let start = 0;
|
||||
let quoteStart: number | null = null;
|
||||
for (let index = 0; index <= input.length; index++) {
|
||||
if (input[index] === '"') {
|
||||
quoteStart = quoteStart === null ? index : null;
|
||||
}
|
||||
if (
|
||||
index === input.length || (quoteStart === null && /\s/.test(input[index]))
|
||||
) {
|
||||
if (index > start) result.push(input.slice(start, index));
|
||||
start = index + 1;
|
||||
}
|
||||
}
|
||||
if (quoteStart !== null) {
|
||||
throw new Error(
|
||||
`Unterminated quote at column ${
|
||||
quoteStart + 1
|
||||
}; add a closing double quote.`,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function ticketSearchNeedsTags(input: string): boolean {
|
||||
return tokens(input).some((token) => token.startsWith("tag:"));
|
||||
}
|
||||
|
||||
function qualifierValue(
|
||||
raw: string,
|
||||
qualifier: string,
|
||||
): { value: string; quoted: boolean } {
|
||||
const quoted = raw.startsWith('"');
|
||||
const value = quoted && raw.endsWith('"') ? raw.slice(1, -1) : raw;
|
||||
if (value.includes('"')) {
|
||||
throw new Error(
|
||||
`Invalid ${qualifier}: value; put the entire value inside one pair of double quotes.`,
|
||||
);
|
||||
}
|
||||
if (!value.trim()) {
|
||||
throw new Error(
|
||||
`Empty ${qualifier}: value; provide a value immediately after the colon.`,
|
||||
);
|
||||
}
|
||||
return { value, quoted };
|
||||
}
|
||||
|
||||
function tagId(value: string, tags: readonly WorkspaceTag[]): string {
|
||||
const byId = tags.find((tag) => tag.id === value);
|
||||
if (byId) {
|
||||
if (byId.deleted) {
|
||||
throw new Error(`Tag "${value}" is deleted; choose an active tag.`);
|
||||
}
|
||||
return byId.id;
|
||||
}
|
||||
const matches = tags.filter((tag) => !tag.deleted && tag.name === value);
|
||||
const ids = [...new Set(matches.map((tag) => tag.id))];
|
||||
if (ids.length > 1) {
|
||||
throw new Error(`Tag name "${value}" is ambiguous; use its stable tag ID.`);
|
||||
}
|
||||
if (ids.length === 0) {
|
||||
throw new Error(
|
||||
`Unknown or deleted tag "${value}"; use an active tag name or stable ID.`,
|
||||
);
|
||||
}
|
||||
return ids[0];
|
||||
}
|
||||
|
||||
function principal(value: string): TicketAssignmentPrincipalSnapshot {
|
||||
if (value === "orchestrator") {
|
||||
return { kind: "workspace_agent", agent_key: "workspace-orchestrator" };
|
||||
}
|
||||
const parts = value.split("/");
|
||||
if (parts.every((part) => part.length > 0 && !/\s/.test(part))) {
|
||||
if (parts[0] === "user" && parts.length === 2) {
|
||||
return { kind: "user", account_id: parts[1] };
|
||||
}
|
||||
if (parts[0] === "worker" && parts.length === 3) {
|
||||
return { kind: "worker", runtime_id: parts[1], worker_id: parts[2] };
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
"Invalid assignee: value; use orchestrator, user/<account-id>, or worker/<runtime-id>/<worker-id>.",
|
||||
);
|
||||
}
|
||||
|
||||
function reason(value: string, quoted: boolean): TicketClosureReason {
|
||||
// 引用符は組込み理由をcustomへ変換しない。非組込みの完全一致テキストだけをotherへ送る。
|
||||
switch (value) {
|
||||
case "completed":
|
||||
case "not_planned":
|
||||
case "duplicate":
|
||||
case "unknown":
|
||||
return value;
|
||||
default:
|
||||
if (quoted) return { other: value };
|
||||
throw new Error(
|
||||
"Invalid reason: value; use completed, not_planned, duplicate, unknown, or double-quote custom exact text.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** 既定検索の適用はloaderが担い、空入力やstate省略では全stateを検索する。 */
|
||||
export function compileTicketSearch(
|
||||
input: string,
|
||||
tags: readonly WorkspaceTag[],
|
||||
): TicketQueryRequest {
|
||||
const request: TicketQueryRequest = {
|
||||
query: null,
|
||||
states: [],
|
||||
tag_ids: [],
|
||||
assigned_principal: null,
|
||||
handling_status: null,
|
||||
sort: "updated_desc",
|
||||
limit: 30 as TicketQueryRequest["limit"],
|
||||
cursor: null,
|
||||
};
|
||||
const text: string[] = [];
|
||||
const tagIds = new Set<string>();
|
||||
const singletons = new Map<string, string>();
|
||||
for (const token of tokens(input)) {
|
||||
const match = /^(state|tag|assignee|reason):(.*)$/s.exec(token);
|
||||
if (!match) {
|
||||
// 未知のqualifierやURLはそのまま残す。引用された通常文もfilterとして解釈しない。
|
||||
text.push(
|
||||
token.startsWith('"') && token.endsWith('"')
|
||||
? token.slice(1, -1)
|
||||
: token,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const [, qualifier, raw] = match;
|
||||
const { value, quoted } = qualifierValue(raw, qualifier);
|
||||
if (qualifier === "tag") {
|
||||
tagIds.add(tagId(value, tags));
|
||||
continue;
|
||||
}
|
||||
let canonical: string;
|
||||
switch (qualifier) {
|
||||
case "state":
|
||||
if (value !== "open" && value !== "closed" && value !== "all") {
|
||||
throw new Error(
|
||||
"Invalid state: value; use open, closed, or all (not close).",
|
||||
);
|
||||
}
|
||||
canonical = value;
|
||||
request.states = value === "all" ? [] : [value];
|
||||
break;
|
||||
case "assignee":
|
||||
request.assigned_principal = principal(value);
|
||||
canonical = JSON.stringify(request.assigned_principal);
|
||||
break;
|
||||
default:
|
||||
request.closure_reason = reason(value, quoted);
|
||||
canonical = JSON.stringify(request.closure_reason);
|
||||
break;
|
||||
}
|
||||
if (singletons.has(qualifier) && singletons.get(qualifier) !== canonical) {
|
||||
throw new Error(
|
||||
`Conflicting ${qualifier}: values; keep only one ${qualifier}: filter.`,
|
||||
);
|
||||
}
|
||||
singletons.set(qualifier, canonical);
|
||||
}
|
||||
request.query = text.join(" ").trim() || null;
|
||||
request.tag_ids = [...tagIds];
|
||||
return request;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
// laneは案件状態のopen/closedだけを表し、狭い画面では縦に並べる。
|
||||
// 担当の自己申告handlingと分類タグは別に示し、laneやWorker稼働状態へ読み替えない。
|
||||
// 検索条件とページ順序はBackendのqueryに任せ、取得済みの一部を並べ替えない。
|
||||
// 案件状態と担当の自己申告は、同じ行でも別の情報として表示する。
|
||||
import { goto } from "$app/navigation";
|
||||
import { untrack } from "svelte";
|
||||
import type { ApiResult } from "#lib/workspace/api/http.ts";
|
||||
@@ -18,27 +18,17 @@
|
||||
} from "#lib/workspace/api/ticket-browser.ts";
|
||||
import type {
|
||||
NewTicket,
|
||||
QueryPage,
|
||||
TicketQueryItem as TicketSummary,
|
||||
TicketState,
|
||||
TicketTarget,
|
||||
TicketTargetAccess,
|
||||
} from "#lib/generated/ticket-api.ts";
|
||||
import { ticketHref } from "#lib/workspace/resource-links.ts";
|
||||
import {
|
||||
ticketQueryRequest, currentOrchestratorAssignment, assignmentPrincipalLabel, handlingLabel, handlingSummary, closureReasonLabel,
|
||||
currentOrchestratorAssignment, assignmentPrincipalLabel, handlingLabel, closureReasonLabel,
|
||||
type WorkspaceOrchestratorStatus,
|
||||
} from "#lib/workspace/tickets/ticket-panel.ts";
|
||||
import type { PageData } from "./$types";
|
||||
|
||||
type LaneState = {
|
||||
states: TicketState[];
|
||||
tickets: TicketSummary[];
|
||||
page: QueryPage | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
type EditableTicketTarget = {
|
||||
repository_key: string;
|
||||
ref_selector: string;
|
||||
@@ -46,19 +36,21 @@
|
||||
};
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
function initialLanes(): Record<string, LaneState> {
|
||||
return Object.fromEntries(Object.entries(data.ticketLanes).flatMap(([laneId, result]) => result ? [[laneId, {
|
||||
states: [laneId as TicketState], tickets: result.data?.items ?? [], page: result.data?.page ?? null,
|
||||
loading: false, error: result.error,
|
||||
}]] : []));
|
||||
}
|
||||
let laneState = $state<Record<string, LaneState>>(untrack(initialLanes));
|
||||
let principal = $state(untrack(() => data.filters.principal));
|
||||
let closureChoice = $state(untrack(() => data.filters.closureReason));
|
||||
let filtersBusy = $state(false);
|
||||
$effect(() => { const currentData = data; untrack(() => {
|
||||
laneState = initialLanes(); principal = currentData.filters.principal; closureChoice = currentData.filters.closureReason; orchestrator = currentData.orchestrator;
|
||||
}); });
|
||||
let response = $state(untrack(() => data.tickets));
|
||||
let searchText = $state(untrack(() => data.search));
|
||||
let searchBusy = $state(false);
|
||||
let pageBusy = $state(false);
|
||||
let navigationError = $state<string | null>(null);
|
||||
$effect(() => {
|
||||
const currentData = data;
|
||||
untrack(() => {
|
||||
response = currentData.tickets;
|
||||
searchText = currentData.search;
|
||||
pageBusy = false;
|
||||
navigationError = null;
|
||||
orchestrator = currentData.orchestrator;
|
||||
});
|
||||
});
|
||||
let orchestrator = $state<ApiResult<WorkspaceOrchestratorStatus>>(
|
||||
untrack(() => data.orchestrator),
|
||||
);
|
||||
@@ -82,15 +74,21 @@
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const lanes = $derived(Object.entries(laneState).map(([id, lane]) => ({ id, label: id === "open" ? "Open" : "Closed", tickets: lane.tickets })));
|
||||
|
||||
async function applyFilters(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget as HTMLFormElement;
|
||||
const search = new URLSearchParams();
|
||||
for (const [key, value] of new FormData(form)) if (typeof value === "string" && value) search.append(key, value);
|
||||
filtersBusy = true;
|
||||
try { await goto(`?${search}`); } finally { filtersBusy = false; }
|
||||
async function searchTickets(event?: SubmitEvent) {
|
||||
event?.preventDefault();
|
||||
if (searchBusy) return;
|
||||
searchBusy = true;
|
||||
navigationError = null;
|
||||
try {
|
||||
const query = new URLSearchParams({ q: searchText.trim() });
|
||||
await goto(`/w/${encodeURIComponent(data.workspaceId)}/tickets?${query}`, {
|
||||
reset: false, refreshAll: true,
|
||||
});
|
||||
} catch (error) {
|
||||
navigationError = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
searchBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function mergeTickets(
|
||||
@@ -102,31 +100,28 @@
|
||||
return [...byId.values()];
|
||||
}
|
||||
|
||||
async function loadMore(laneId: string): Promise<void> {
|
||||
const lane = laneState[laneId];
|
||||
if (!lane || lane.loading || (lane.page && !lane.page.has_more)) {
|
||||
return;
|
||||
}
|
||||
lane.loading = true;
|
||||
lane.error = null;
|
||||
try {
|
||||
const result = await loadJson(
|
||||
fetch,
|
||||
workspaceApiPath(data.workspaceId, "/tickets/query"),
|
||||
{ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(ticketQueryRequest(data.filters, lane.states, lane.page?.next_cursor ?? null)) },
|
||||
parseTicketQueryResponse,
|
||||
TICKET_BROWSER_API_LOAD_POLICY,
|
||||
);
|
||||
if (!result.data) {
|
||||
throw new Error(result.error ?? "追加読み込みに失敗しました");
|
||||
}
|
||||
lane.tickets = mergeTickets(lane.tickets, result.data.items);
|
||||
lane.page = result.data.page;
|
||||
} catch (error) {
|
||||
lane.error = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
lane.loading = false;
|
||||
}
|
||||
async function loadMore(): Promise<void> {
|
||||
if (pageBusy || searchBusy || !data.queryRequest || (response.data && !response.data.page.has_more)) return;
|
||||
const observedData = data;
|
||||
const previous = response.data;
|
||||
pageBusy = true;
|
||||
response = { data: previous, error: null };
|
||||
const result = await loadJson(
|
||||
fetch,
|
||||
workspaceApiPath(observedData.workspaceId, "/tickets/query"),
|
||||
{
|
||||
method: "POST", headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ...observedData.queryRequest, cursor: previous?.page.next_cursor ?? null }),
|
||||
},
|
||||
parseTicketQueryResponse,
|
||||
TICKET_BROWSER_API_LOAD_POLICY,
|
||||
);
|
||||
// 旧query/Workspaceの遅延応答で、現在の一覧やページ状態を上書きしない。
|
||||
if (data !== observedData) return;
|
||||
response = result.data
|
||||
? { data: { ...result.data, items: mergeTickets(previous?.items ?? [], result.data.items) }, error: null }
|
||||
: { data: previous, error: result.error };
|
||||
pageBusy = false;
|
||||
}
|
||||
|
||||
function addCreateTarget(): void {
|
||||
@@ -288,98 +283,91 @@
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<form class="ticket-query-form" onsubmit={applyFilters} aria-label="Ticket filters">
|
||||
<label>Search<input name="q" value={data.filters.query} type="search" /></label>
|
||||
<label>Case<select name="case" value={data.filters.caseState}><option value="all">Open and closed</option><option value="open">Open</option><option value="closed">Closed</option></select></label>
|
||||
<label>Assigned principal<select name="principal" bind:value={principal}><option value="all">Any</option><option value="orchestrator">Orchestrator</option><option value="user">User</option><option value="worker">Worker</option></select></label>
|
||||
{#if principal === "user"}<label>Account ID<input name="account" value={data.filters.accountId} required /></label>{/if}
|
||||
{#if principal === "worker"}<label>Runtime ID<input name="runtime" value={data.filters.runtimeId} required /></label><label>Worker ID<input name="worker" value={data.filters.workerId} required /></label>{/if}
|
||||
<fieldset><legend>Classification tags (all selected)</legend>
|
||||
{#each data.tags.data ?? [] as tag (tag.id)}
|
||||
{#if !tag.deleted}<label class="ticket-tag-filter"><input type="checkbox" name="tag" value={tag.id} checked={data.filters.tagIds.includes(tag.id)} />{tag.name}</label>{/if}
|
||||
{:else}<span class="workspace-empty-copy">No tags</span>{/each}
|
||||
{#if data.tags.error}<p class="workspace-callout is-error" role="alert">Tags: {data.tags.error}</p>{/if}
|
||||
</fieldset>
|
||||
<details class="ticket-reason-filter" open={data.filters.closureReason !== "all"}><summary>Close reason filter</summary>
|
||||
<label>Current close reason<select name="reason" bind:value={closureChoice}><option value="all">Any reason</option><option value="completed">Completed</option><option value="not_planned">Not planned</option><option value="duplicate">Duplicate</option><option value="unknown">Unknown</option><option value="other">Other (exact text)</option></select></label>
|
||||
{#if closureChoice === "other"}<label>Close reason text<input name="reason_text" value={data.filters.closureText} required /></label>{/if}
|
||||
<p class="workspace-empty-copy">Matches current closed cases. Search also includes past close reasons.</p>
|
||||
</details>
|
||||
<button class="workspace-secondary-button" disabled={filtersBusy}>{filtersBusy ? "Searching…" : "Apply filters"}</button>
|
||||
<a href={`/w/${encodeURIComponent(data.workspaceId)}/orchestrator`}>Orchestrator handling</a>
|
||||
<form class="ticket-query-form" onsubmit={searchTickets} aria-label="Search tickets">
|
||||
<input name="q" type="search" aria-label="Search tickets" bind:value={searchText}
|
||||
placeholder="Search tickets · state:open" spellcheck="false" />
|
||||
<button class="workspace-secondary-button" type="submit" disabled={searchBusy}>
|
||||
{searchBusy ? "Searching…" : "Search"}
|
||||
</button>
|
||||
</form>
|
||||
<div class="ticket-search-context">
|
||||
<details class="ticket-search-help">
|
||||
<summary>Search syntax</summary>
|
||||
<p>Combine text with <code>state:open</code> or <code>state:closed</code>. Omit state to search both.</p>
|
||||
<p><code>tag:"UI bug"</code> · <code>assignee:orchestrator</code> · <code>reason:completed</code></p>
|
||||
<p>Other principals: <code>assignee:user/account-id</code> or <code>assignee:worker/runtime-id/worker-id</code>.
|
||||
Custom close reason: <code>reason:"Superseded plan"</code>.</p>
|
||||
</details>
|
||||
<a href={'/w/' + encodeURIComponent(data.workspaceId) + '/orchestrator'}>Orchestrator handling</a>
|
||||
</div>
|
||||
{#if navigationError}<p class="workspace-callout is-error" role="alert">{navigationError}</p>{/if}
|
||||
|
||||
<section class="ticket-kanban" aria-label="Ticket cases">
|
||||
{#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>
|
||||
<span class="ticket-state-dot"></span>
|
||||
<h2>{lane.label}</h2>
|
||||
</div>
|
||||
<span class="ticket-lane-count">{lane.tickets.length} loaded</span>
|
||||
</header>
|
||||
<div
|
||||
class="ticket-lane-cards"
|
||||
data-lane-id={lane.id}
|
||||
>
|
||||
{#each lane.tickets as ticket (ticket.id)}
|
||||
{@const assignment = currentOrchestratorAssignment(ticket)}
|
||||
<a
|
||||
class="ticket-card"
|
||||
href={ticketHref(data.workspaceId, ticket)}
|
||||
>
|
||||
<span class="ticket-card-id">{ticket.resource_key}</span>
|
||||
<strong>{ticket.title}</strong>
|
||||
<div class="ticket-card-meta">
|
||||
<span>Case: {ticket.state} · {ticket.priority}</span>
|
||||
<time>{prettyDate(ticket.updated_at)}</time>
|
||||
<section class="ticket-list" aria-label="Tickets" aria-busy={searchBusy || pageBusy}>
|
||||
{#if response.data}
|
||||
<p class="ticket-list-count" aria-live="polite">{response.data.items.length} tickets loaded</p>
|
||||
{/if}
|
||||
<ul class="ticket-list-rows">
|
||||
{#each response.data?.items ?? [] as ticket (ticket.id)}
|
||||
{@const assignment = currentOrchestratorAssignment(ticket)}
|
||||
<li>
|
||||
<a class="ticket-list-row" data-state={ticket.state} href={ticketHref(data.workspaceId, ticket)}>
|
||||
<span class="ticket-list-state">{ticket.state === "open" ? "Open" : "Closed"}</span>
|
||||
<div class="ticket-list-content">
|
||||
<strong class="ticket-list-title">{ticket.title}</strong>
|
||||
<div class="ticket-list-meta">
|
||||
<span class="ticket-list-key">{ticket.resource_key}</span>
|
||||
<time datetime={ticket.updated_at ?? undefined}>{prettyDate(ticket.updated_at)}</time>
|
||||
{#each ticket.tags as tag (tag.id)}<span class="ticket-list-tag">{tag.name}</span>{/each}
|
||||
{#if ticket.current_closure}<span>Close reason: {closureReasonLabel(ticket.current_closure.reason)}</span>{/if}
|
||||
{#if assignment}<span title={assignmentPrincipalLabel(assignment.principal)}>Handling: {handlingLabel(assignment.tracking?.status)} ({assignment.tracking ? "self-reported" : "not reported"})</span>{/if}
|
||||
</div>
|
||||
{#if ticket.current_closure}<span class="ticket-closure-summary">Close reason: {closureReasonLabel(ticket.current_closure.reason)}</span>{/if}
|
||||
<span class="ticket-classification">Tags: {ticket.tags.length ? ticket.tags.map((tag) => tag.name).join(", ") : "None"}</span>
|
||||
<div class="ticket-handling-summary">
|
||||
<span>Orchestrator handling: {assignment ? handlingLabel(assignment.tracking?.status) : "Not assigned"}{#if assignment}{" "}<small>({assignment.tracking ? "self-reported" : "not reported"})</small>{/if}</span>
|
||||
{#if assignment}<span>Assigned principal: {assignmentPrincipalLabel(assignment.principal)}</span>{/if}
|
||||
<span>{handlingSummary(assignment)}</span>
|
||||
</div>
|
||||
</a>
|
||||
{:else}
|
||||
{#if !pagination?.error}<div class="ticket-lane-empty">No tickets</div>{/if}
|
||||
{/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?.page?.has_more}
|
||||
<button class="workspace-secondary-button" type="button" onclick={() => loadMore(lane.id)}>Load more {lane.label.toLowerCase()} tickets</button>
|
||||
{:else if pagination?.page && !pagination.page.has_more && lane.tickets.length > 0}
|
||||
<p class="ticket-lane-page-state">End of lane</p>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
{/each}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if response.error}
|
||||
<div class="ticket-list-error" role="alert">
|
||||
<span>{response.error}</span>
|
||||
<button class="workspace-secondary-button" type="button" disabled={pageBusy || searchBusy}
|
||||
onclick={() => data.queryRequest ? loadMore() : searchTickets()}>Retry</button>
|
||||
</div>
|
||||
{:else if !response.data?.items.length && !searchBusy}
|
||||
<p class="ticket-list-empty">No tickets match this query.</p>
|
||||
{/if}
|
||||
{#if pageBusy}
|
||||
<p class="ticket-list-page-state" aria-live="polite">Loading…</p>
|
||||
{:else if response.data?.page.has_more && !response.error}
|
||||
<button class="workspace-secondary-button ticket-load-more" type="button" disabled={searchBusy} onclick={loadMore}>Load more tickets</button>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.ticket-query-form { display: flex; flex-wrap: wrap; align-items: end; gap: var(--space-3); margin-block: var(--space-4); }
|
||||
.ticket-query-form > label { display: grid; gap: var(--space-1); min-width: 0; flex: 1 1 10rem; }
|
||||
.ticket-query-form input:not([type="checkbox"]), .ticket-query-form select { min-width: 0; width: 100%; padding: var(--space-2); color: var(--text); background: var(--bg-raised); border: 1px solid var(--line); border-radius: var(--radius-soft); font: inherit; }
|
||||
.ticket-query-form fieldset { display: flex; flex-wrap: wrap; gap: var(--space-3); border: 0; padding: 0; flex: 1 1 100%; min-width: 0; }
|
||||
.ticket-tag-filter { display: flex; align-items: center; gap: var(--space-1); overflow-wrap: anywhere; }
|
||||
.ticket-reason-filter { flex: 1 1 100%; }
|
||||
.ticket-reason-filter label { display: grid; gap: var(--space-1); max-width: 28rem; margin-block: var(--space-2); }
|
||||
.ticket-reason-filter summary { cursor: pointer; }
|
||||
.ticket-query-form legend { margin-bottom: var(--space-2); }
|
||||
.ticket-kanban { grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr)); align-items: start; overflow: visible; }
|
||||
.ticket-lane { height: auto; min-height: 0; max-height: none; }
|
||||
.ticket-lane-cards { max-height: none; overflow: visible; }
|
||||
.ticket-card { overflow-wrap: anywhere; }
|
||||
.ticket-handling-summary { display: grid; gap: var(--space-1); border-top: 1px solid var(--line); padding-top: var(--space-2); color: var(--text-muted); }
|
||||
.ticket-classification { color: var(--text-muted); }
|
||||
.ticket-closure-summary { font-weight: 600; }
|
||||
.ticket-query-form { display: flex; align-items: stretch; gap: var(--space-2); margin-top: var(--space-4); }
|
||||
.ticket-query-form input { flex: 1; min-width: 0; padding: var(--space-2) var(--space-3); color: var(--text); background: var(--bg); border: 1px solid var(--line); border-radius: var(--radius-soft); font: inherit; }
|
||||
.ticket-query-form input:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.ticket-search-context { display: flex; align-items: start; justify-content: space-between; flex-wrap: wrap; gap: var(--space-2); margin-block: var(--space-2) var(--space-4); color: var(--text-muted); font-size: var(--font-size-compact); }
|
||||
.ticket-search-context a { color: inherit; }
|
||||
.ticket-search-help { min-width: 0; max-width: 100%; }
|
||||
.ticket-search-help summary { cursor: pointer; width: fit-content; }
|
||||
.ticket-search-help p { margin-block: var(--space-2); overflow-wrap: anywhere; }
|
||||
.ticket-search-help code { font-size: inherit; }
|
||||
.ticket-list { min-width: 0; }
|
||||
.ticket-list-count { margin: 0; padding-block: var(--space-2); color: var(--text-muted); font-size: var(--font-size-compact); }
|
||||
.ticket-list-rows { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--line); }
|
||||
.ticket-list-row { display: grid; grid-template-columns: 4.5rem minmax(0, 1fr); align-items: start; gap: var(--space-2); padding-block: var(--space-3); border-bottom: 1px solid var(--line); color: var(--text); text-decoration: none; }
|
||||
.ticket-list-row:hover { background: var(--interactive-hover); }
|
||||
.ticket-list-row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||
.ticket-list-state { padding-top: 2px; font-size: var(--font-size-compact); color: var(--text-muted); }
|
||||
.ticket-list-row[data-state="open"] .ticket-list-state { color: var(--success); }
|
||||
.ticket-list-content { min-width: 0; }
|
||||
.ticket-list-title { display: block; font-size: var(--font-size-body); line-height: var(--line-height-body); color: var(--text-strong); overflow-wrap: anywhere; }
|
||||
.ticket-list-meta { display: flex; flex-wrap: wrap; gap: var(--space-1) var(--space-3); margin-top: var(--space-1); font-size: var(--font-size-compact); color: var(--text-muted); overflow-wrap: anywhere; }
|
||||
.ticket-list-key { font-family: var(--font-mono); }
|
||||
.ticket-list-tag { color: var(--text); }
|
||||
.ticket-list-empty, .ticket-list-page-state { padding-block: var(--space-4); margin: 0; color: var(--text-muted); }
|
||||
.ticket-list-error { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-3); padding-block: var(--space-4); color: var(--danger); overflow-wrap: anywhere; }
|
||||
.ticket-load-more { margin-block: var(--space-4); }
|
||||
@media (max-width: 480px) { .ticket-list-row { grid-template-columns: 3.5rem minmax(0, 1fr); } }
|
||||
</style>
|
||||
|
||||
@@ -7,46 +7,74 @@ import {
|
||||
} from "#lib/workspace/api/ticket-browser.ts";
|
||||
import { parseTags } from "#lib/workspace/api/ticket-operations.ts";
|
||||
import {
|
||||
readTicketFilters,
|
||||
ticketQueryRequest,
|
||||
} from "#lib/workspace/tickets/ticket-panel.ts";
|
||||
import type { TicketState } from "#lib/generated/ticket-api.ts";
|
||||
compileTicketSearch,
|
||||
DEFAULT_TICKET_SEARCH,
|
||||
ticketSearchNeedsTags,
|
||||
} from "#lib/workspace/tickets/ticket-search.ts";
|
||||
import type {
|
||||
TicketQueryRequest,
|
||||
WorkspaceTag,
|
||||
} from "#lib/generated/ticket-api.ts";
|
||||
import type { PageLoad } from "./$types";
|
||||
|
||||
export const load: PageLoad = async ({ fetch, params, url }) => {
|
||||
const workspaceId = params.workspaceId;
|
||||
const filters = readTicketFilters(url.searchParams);
|
||||
const loadLane = async (state: TicketState) => {
|
||||
if (filters.caseState !== "all" && filters.caseState !== state) return null;
|
||||
const search = url.searchParams.get("q") ?? DEFAULT_TICKET_SEARCH;
|
||||
const loadTickets = async () => {
|
||||
let queryRequest: TicketQueryRequest | null = null;
|
||||
try {
|
||||
return await loadJson(
|
||||
const tags: WorkspaceTag[] = [];
|
||||
// タグ名をstable IDへ解決する場合だけ一覧を取得する。状態検索をタグAPIに依存させない。
|
||||
if (ticketSearchNeedsTags(search)) {
|
||||
let after = "";
|
||||
while (true) {
|
||||
const result = await loadJson(
|
||||
fetch,
|
||||
workspaceApiPath(
|
||||
workspaceId,
|
||||
`/ticket-tags?limit=200${
|
||||
after ? `&after=${encodeURIComponent(after)}` : ""
|
||||
}`,
|
||||
),
|
||||
undefined,
|
||||
parseTags,
|
||||
TICKET_BROWSER_API_LOAD_POLICY,
|
||||
);
|
||||
if (!result.data) {
|
||||
throw new Error(result.error ?? "Tags could not be loaded");
|
||||
}
|
||||
tags.push(...result.data);
|
||||
if (result.data.length < 200) break;
|
||||
const next = result.data.at(-1)!.id;
|
||||
if (next <= after) throw new Error("Tag pagination did not advance");
|
||||
after = next;
|
||||
}
|
||||
}
|
||||
queryRequest = compileTicketSearch(search, tags);
|
||||
const tickets = await loadJson(
|
||||
fetch,
|
||||
workspaceApiPath(workspaceId, "/tickets/query"),
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(ticketQueryRequest(filters, [state])),
|
||||
body: JSON.stringify(queryRequest),
|
||||
},
|
||||
parseTicketQueryResponse,
|
||||
TICKET_BROWSER_API_LOAD_POLICY,
|
||||
);
|
||||
return { queryRequest, tickets };
|
||||
} catch (error) {
|
||||
return {
|
||||
data: null,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
queryRequest,
|
||||
tickets: {
|
||||
data: null,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
const [open, closed, tags, orchestrator, repositories] = await Promise.all([
|
||||
loadLane("open"),
|
||||
loadLane("closed"),
|
||||
loadJson(
|
||||
fetch,
|
||||
workspaceApiPath(workspaceId, "/ticket-tags"),
|
||||
undefined,
|
||||
parseTags,
|
||||
TICKET_BROWSER_API_LOAD_POLICY,
|
||||
),
|
||||
const [result, orchestrator, repositories] = await Promise.all([
|
||||
loadTickets(),
|
||||
loadJson(
|
||||
fetch,
|
||||
workspaceApiPath(workspaceId, "/orchestrator"),
|
||||
@@ -55,12 +83,5 @@ export const load: PageLoad = async ({ fetch, params, url }) => {
|
||||
),
|
||||
loadWorkspaceRepositoryList(fetch, workspaceId),
|
||||
]);
|
||||
return {
|
||||
workspaceId,
|
||||
filters,
|
||||
ticketLanes: { open, closed },
|
||||
tags,
|
||||
orchestrator,
|
||||
repositories,
|
||||
};
|
||||
return { workspaceId, search, ...result, orchestrator, repositories };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user