feat: replace Ticket kanban with query-based list
This commit is contained in:
@@ -33,9 +33,9 @@ Workspace Webは、Workspaceの確定した状態を確認し、注意が必要
|
||||
|
||||
詳細では、対象の識別情報、現在の状態、注意が必要な事項を最初に示し、本文や履歴を主領域に置く。関連、target、review、案件判断、担当のhandlingをそれぞれ別の主体が確定する場合は、独立したsectionに分ける。
|
||||
|
||||
## Ticket board
|
||||
## Ticket一覧
|
||||
|
||||
boardのlaneは案件状態の`open`/`closed`を表し、狭い画面では縦に並べる。`open`を着手可能・queue済み、`closed`を要求実現済みと読み替えない。終了理由と説明を案件の判断として示し、再開・訂正でも過去の判断を残す。
|
||||
Ticketは平坦なリストで表示し、検索条件は単一のクエリ欄にまとめる。状態別のlaneや独立したfilter群は設けず、案件状態は`state:open`/`state:closed`で指定する。`open`を着手可能・queue済み、`closed`を要求実現済みと読み替えない。終了理由と説明を案件の判断として示し、再開・訂正でも過去の判断を残す。
|
||||
|
||||
各Ticketにはkey、title、優先判断を変える情報を表示する。分類タグ、担当principal、自己申告のhandlingを案件状態と分けて示す。検索と絞込みは取得済みの一部分ではなく、Backendのqueryへ適用してからページを取得する。
|
||||
|
||||
|
||||
@@ -8,8 +8,7 @@ import { startOwnedProcesses, stopOwnedProcesses } from "../src/processes.ts";
|
||||
async function withFixture(
|
||||
run: (browser: Browser, baseUrl: string) => Promise<void>,
|
||||
) {
|
||||
const scenarioPath =
|
||||
new URL("../scenarios/tickets.json", import.meta.url).pathname;
|
||||
const scenarioPath = new URL("../scenarios/tickets.json", import.meta.url).pathname;
|
||||
const scenario = await loadScenario(scenarioPath);
|
||||
const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 });
|
||||
const port = (listener.addr as Deno.NetAddr).port;
|
||||
@@ -17,8 +16,7 @@ async function withFixture(
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
scenario.processes![0].args![4] = String(port);
|
||||
scenario.processes![0].readyUrl = `${baseUrl}/health`;
|
||||
const logs =
|
||||
new URL(`../../../target/web-ux/t743-browser/${port}/logs`, import.meta.url)
|
||||
const logs = new URL(`../../../target/web-ux/t743-browser/${port}/logs`, import.meta.url)
|
||||
.pathname;
|
||||
const processes = await startOwnedProcesses(
|
||||
scenario.processes!,
|
||||
@@ -57,65 +55,58 @@ function queryResponse(
|
||||
);
|
||||
}
|
||||
|
||||
Deno.test("Ticket filters and Orchestrator handling query the server beyond loaded pages and preserve filters on pagination", async () => {
|
||||
async function search(page: Page, value: string) {
|
||||
await page.getByRole("searchbox", { name: "Search tickets", exact: true }).fill(value);
|
||||
await page.getByRole("button", { name: "Search", exact: true }).click();
|
||||
await page.waitForURL((url) => url.searchParams.get("q") === value);
|
||||
}
|
||||
function ticketKey(page: Page, key: string) {
|
||||
return page.locator(".ticket-list-key").filter({ hasText: new RegExp(`^${key}$`) });
|
||||
}
|
||||
async function queryRequests(base: string) {
|
||||
const requests = await (await fetch(`${base}/fixture-state`)).json();
|
||||
return requests.filter((row: { path: string }) => row.path === "/tickets/query");
|
||||
}
|
||||
|
||||
Deno.test("single Ticket query searches beyond loaded rows and preserves qualifiers on pagination", async () => {
|
||||
await withFixture(async (browser, base) => {
|
||||
const page = await browser.newPage();
|
||||
await page.goto(`${base}/w/tickets-pages/tickets`);
|
||||
await page.locator('.ticket-lane[data-state="open"] .ticket-card').first()
|
||||
.waitFor();
|
||||
await page.locator(".ticket-list-row").first().waitFor();
|
||||
assertEquals(await page.locator("a.ticket-list-row").count(), 30);
|
||||
assertEquals(await ticketKey(page, "T-35").count(), 0);
|
||||
assertEquals(
|
||||
await page.locator('.ticket-lane[data-state="open"] .ticket-card')
|
||||
.count(),
|
||||
30,
|
||||
await page.getByRole("searchbox", { name: "Search tickets" }).inputValue(),
|
||||
"state:open",
|
||||
);
|
||||
assertEquals(
|
||||
await page.locator(".ticket-card-id").filter({ hasText: /^T-35$/ })
|
||||
.count(),
|
||||
0,
|
||||
);
|
||||
await page.getByRole("checkbox", { name: "UI", exact: true }).check();
|
||||
await page.getByRole("combobox", {
|
||||
name: "Assigned principal",
|
||||
exact: true,
|
||||
}).selectOption("orchestrator");
|
||||
const filtered = queryResponse(
|
||||
page,
|
||||
(body) =>
|
||||
(body.tag_ids as string[])?.includes("tag-ui") &&
|
||||
(body.assigned_principal as { kind: string })?.kind ===
|
||||
"workspace_agent" &&
|
||||
(body.states as string[]).includes("open"),
|
||||
);
|
||||
await page.getByRole("button", { name: "Apply filters", exact: true })
|
||||
.click();
|
||||
const response = await filtered;
|
||||
assertEquals(response.request().postDataJSON(), {
|
||||
assertEquals(await page.getByRole("form", { name: "Search tickets" }).count(), 1);
|
||||
assertEquals(await page.locator(".ticket-kanban, .ticket-lane").count(), 0);
|
||||
assertEquals(await page.getByRole("combobox").count(), 0);
|
||||
assertEquals(await page.getByRole("checkbox").count(), 0);
|
||||
assertEquals((await queryRequests(base)).length, 1);
|
||||
const filtered = queryResponse(page, (body) => (body.tag_ids as string[])?.includes("tag-ui"));
|
||||
await search(page, 'state:open tag:"UI" assignee:orchestrator');
|
||||
assertEquals((await filtered).request().postDataJSON(), {
|
||||
query: null,
|
||||
states: ["open"],
|
||||
tag_ids: ["tag-ui"],
|
||||
assigned_principal: {
|
||||
kind: "workspace_agent",
|
||||
agent_key: "workspace-orchestrator",
|
||||
},
|
||||
assigned_principal: { kind: "workspace_agent", agent_key: "workspace-orchestrator" },
|
||||
handling_status: null,
|
||||
sort: "updated_desc",
|
||||
limit: 30,
|
||||
cursor: null,
|
||||
});
|
||||
await page.locator(".ticket-card-id").filter({ hasText: /^T-35$/ })
|
||||
.waitFor();
|
||||
assertEquals(
|
||||
await page.locator('.ticket-lane[data-state="open"] .ticket-card')
|
||||
.count(),
|
||||
2,
|
||||
);
|
||||
await ticketKey(page, "T-35").waitFor();
|
||||
assertEquals(await page.locator(".ticket-list-row").count(), 2);
|
||||
assertEquals((await queryRequests(base)).length, 2);
|
||||
await page.goto(
|
||||
`${base}/w/tickets-pages/tickets?case=open&principal=orchestrator&tag=tag-investigation`,
|
||||
`${base}/w/tickets-pages/tickets?q=${
|
||||
encodeURIComponent("state:open assignee:orchestrator tag:tag-investigation")
|
||||
}`,
|
||||
);
|
||||
await page.getByRole("button", { name: "Load more open tickets" })
|
||||
.waitFor();
|
||||
await page.getByRole("button", { name: "Load more tickets", exact: true }).waitFor();
|
||||
const next = queryResponse(page, (body) => body.cursor === "30");
|
||||
await page.getByRole("button", { name: "Load more open tickets" }).click();
|
||||
await page.getByRole("button", { name: "Load more tickets", exact: true }).click();
|
||||
const nextRequest = (await next).request().postDataJSON();
|
||||
assertEquals(nextRequest.tag_ids, ["tag-investigation"]);
|
||||
assertEquals(nextRequest.states, ["open"]);
|
||||
@@ -123,9 +114,22 @@ Deno.test("Ticket filters and Orchestrator handling query the server beyond load
|
||||
kind: "workspace_agent",
|
||||
agent_key: "workspace-orchestrator",
|
||||
});
|
||||
await page.locator(".ticket-card-id").filter({ hasText: /^T-40$/ })
|
||||
.waitFor();
|
||||
assertEquals(await page.locator(".ticket-card").count(), 39);
|
||||
await ticketKey(page, "T-40").waitFor();
|
||||
assertEquals(await page.locator(".ticket-list-row").count(), 39);
|
||||
assertEquals(new Set(await page.locator(".ticket-list-key").allTextContents()).size, 39);
|
||||
const finalRow = page.locator("a.ticket-list-row").filter({ has: ticketKey(page, "T-40") });
|
||||
const href = await finalRow.getAttribute("href");
|
||||
assert(href);
|
||||
await finalRow.focus();
|
||||
await finalRow.press("Enter");
|
||||
await page.waitForURL((url) => url.pathname === new URL(href, base).pathname);
|
||||
await page.locator(".ticket-detail-header").waitFor();
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test("Orchestrator retains handling filters and assignment pagination independently of Ticket search", async () => {
|
||||
await withFixture(async (browser, base) => {
|
||||
const page = await browser.newPage();
|
||||
await page.goto(`${base}/w/tickets-pages/orchestrator?handling=done`);
|
||||
await page.getByRole("button", { name: "Load more assignments" }).waitFor();
|
||||
const moreHandling = queryResponse(
|
||||
@@ -139,19 +143,15 @@ Deno.test("Ticket filters and Orchestrator handling query the server beyond load
|
||||
agent_key: "workspace-orchestrator",
|
||||
});
|
||||
assertEquals(handlingRequest.states, []);
|
||||
await page.locator(".orchestrator-assignment").filter({ hasText: "T-40" })
|
||||
.waitFor();
|
||||
await page.locator(".orchestrator-assignment").filter({ hasText: "T-40" }).waitFor();
|
||||
assertEquals(await page.locator(".orchestrator-assignment").count(), 38);
|
||||
await page.getByRole("combobox", {
|
||||
name: "Handling (self-reported)",
|
||||
exact: true,
|
||||
}).selectOption("waiting");
|
||||
await page.getByRole("combobox", { name: "Handling (self-reported)", exact: true })
|
||||
.selectOption("waiting");
|
||||
const waiting = queryResponse(
|
||||
page,
|
||||
(body) => body.handling_status === "waiting" && body.cursor === null,
|
||||
);
|
||||
await page.getByRole("button", { name: "Apply filters", exact: true })
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Apply filters", exact: true }).click();
|
||||
await waiting;
|
||||
await page.locator(".orchestrator-assignment").filter({
|
||||
hasText: "Awaiting the user's policy choice",
|
||||
@@ -161,55 +161,233 @@ Deno.test("Ticket filters and Orchestrator handling query the server beyond load
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test("current close-reason filtering sends canonical known/custom reasons and never closes cases", async () => {
|
||||
Deno.test("query URL and back-forward restore text, states and a fresh first page", async () => {
|
||||
await withFixture(async (browser, base) => {
|
||||
const page = await browser.newPage();
|
||||
await page.goto(`${base}/w/tickets-pages/tickets`);
|
||||
await page.locator(".ticket-list-row").first().waitFor();
|
||||
const all = queryResponse(page, (body) => (body.states as string[])?.length === 0);
|
||||
await search(page, "");
|
||||
assertEquals((await all).request().postDataJSON().query, null);
|
||||
assertEquals(new URL(page.url()).searchParams.get("q"), "");
|
||||
await ticketKey(page, "T-4").waitFor();
|
||||
const more = queryResponse(page, (body) => body.cursor === "30");
|
||||
await page.getByRole("button", { name: "Load more tickets", exact: true }).click();
|
||||
await more;
|
||||
await ticketKey(page, "T-40").waitFor();
|
||||
const closed = queryResponse(page, (body) => (body.states as string[])?.[0] === "closed");
|
||||
await search(page, "state:closed");
|
||||
assertEquals((await closed).request().postDataJSON().cursor, null);
|
||||
await ticketKey(page, "T-4").waitFor();
|
||||
assertEquals(await page.locator(".ticket-list-row").count(), 1);
|
||||
assertEquals(new URL(page.url()).searchParams.get("q"), "state:closed");
|
||||
const back = queryResponse(
|
||||
page,
|
||||
(body) => (body.states as string[])?.length === 0 && body.cursor === null,
|
||||
);
|
||||
await page.goBack();
|
||||
await back;
|
||||
await ticketKey(page, "T-1").waitFor();
|
||||
assertEquals(await page.getByRole("searchbox", { name: "Search tickets" }).inputValue(), "");
|
||||
assertEquals(await page.locator(".ticket-list-row").count(), 30);
|
||||
const forward = queryResponse(
|
||||
page,
|
||||
(body) => (body.states as string[])?.[0] === "closed" && body.cursor === null,
|
||||
);
|
||||
await page.goForward();
|
||||
await forward;
|
||||
await ticketKey(page, "T-4").waitFor();
|
||||
assertEquals(
|
||||
await page.getByRole("searchbox", { name: "Search tickets" }).inputValue(),
|
||||
"state:closed",
|
||||
);
|
||||
assertEquals(await page.locator(".ticket-list-row").count(), 1);
|
||||
const text = queryResponse(page, (body) => body.query === "investigation");
|
||||
await search(page, "investigation");
|
||||
assertEquals((await text).request().postDataJSON().states, []);
|
||||
await ticketKey(page, "T-2").waitFor();
|
||||
await ticketKey(page, "T-4").waitFor();
|
||||
const stateAll = queryResponse(
|
||||
page,
|
||||
(body) => body.query === null && (body.states as string[])?.length === 0,
|
||||
);
|
||||
await search(page, "state:all");
|
||||
await stateAll;
|
||||
await ticketKey(page, "T-1").waitFor();
|
||||
assertEquals(await page.locator(".ticket-list-row").count(), 30);
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test("quoted free text containing tag syntax does not depend on the tag catalog", async () => {
|
||||
await withFixture(async (browser, base) => {
|
||||
const page = await browser.newPage();
|
||||
let tagRequests = 0;
|
||||
await page.route(/\/ticket-tags(?:\?|$)/, async (route) => {
|
||||
tagRequests++;
|
||||
await route.fulfill({
|
||||
status: 503,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ message: "Tag catalog unavailable" }),
|
||||
});
|
||||
});
|
||||
const response = queryResponse(page, (body) => body.query === "text tag:Ready");
|
||||
await page.goto(`${base}/w/tickets/tickets?q=${encodeURIComponent('"text tag:Ready"')}`);
|
||||
const body = (await response).request().postDataJSON();
|
||||
assertEquals(body.query, "text tag:Ready");
|
||||
assertEquals(body.tag_ids, []);
|
||||
assertEquals(body.states, []);
|
||||
await page.getByText("No tickets match this query.", { exact: true }).waitFor();
|
||||
assertEquals(tagRequests, 0);
|
||||
assertEquals(await page.getByRole("alert").count(), 0);
|
||||
assertEquals((await queryRequests(base)).length, 1);
|
||||
// 障害routeがquery string付きの実際のタグ取得も捕捉することを確認する。
|
||||
await search(page, "tag:Ready");
|
||||
await page.getByRole("alert").waitFor();
|
||||
assertEquals(tagRequests, 1);
|
||||
assertEquals((await queryRequests(base)).length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test("recognized invalid qualifiers show an error without issuing a broad Ticket query", async () => {
|
||||
await withFixture(async (browser, base) => {
|
||||
const page = await browser.newPage();
|
||||
await page.goto(`${base}/w/tickets/tickets`);
|
||||
await page.getByText("Close reason filter", { exact: true }).click();
|
||||
await page.getByRole("combobox", {
|
||||
name: "Current close reason",
|
||||
exact: true,
|
||||
}).selectOption("completed");
|
||||
await page.getByRole("combobox", { name: "Case", exact: true })
|
||||
.selectOption("closed");
|
||||
const response = queryResponse(
|
||||
page,
|
||||
(body) => body.closure_reason === "completed",
|
||||
await page.locator(".ticket-list-row").first().waitFor();
|
||||
const before = (await queryRequests(base)).length;
|
||||
for (
|
||||
const value of [
|
||||
"state:close",
|
||||
"state:waiting",
|
||||
"assignee:user/",
|
||||
"assignee:worker/embedded",
|
||||
"tag:",
|
||||
'reason:"',
|
||||
]
|
||||
) {
|
||||
await search(page, value);
|
||||
await page.waitForURL((url) => url.searchParams.get("q") === value);
|
||||
await page.getByRole("alert").first().waitFor();
|
||||
assertEquals((await queryRequests(base)).length, before);
|
||||
assertEquals(
|
||||
await page.getByRole("button", { name: "Load more tickets", exact: true }).count(),
|
||||
0,
|
||||
);
|
||||
await page.getByRole("button", { name: "Apply filters", exact: true })
|
||||
.click();
|
||||
assertEquals((await response).request().postDataJSON().states, ["closed"]);
|
||||
await page.locator('.ticket-lane[data-state="open"]').waitFor({
|
||||
state: "hidden",
|
||||
}
|
||||
const recovered = queryResponse(page, (body) => (body.states as string[])?.[0] === "open");
|
||||
await search(page, "state:open");
|
||||
await recovered;
|
||||
await ticketKey(page, "T-1").waitFor();
|
||||
assertEquals(await page.getByRole("alert").count(), 0);
|
||||
});
|
||||
assertEquals(await page.locator(".ticket-card").count(), 1);
|
||||
assertEquals(await page.locator(".ticket-card-id").textContent(), "T-4");
|
||||
await page.getByRole("combobox", {
|
||||
name: "Current close reason",
|
||||
exact: true,
|
||||
}).selectOption("other");
|
||||
await page.getByLabel("Close reason text", { exact: true }).fill(
|
||||
"Superseded plan",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("current close-reason query sends canonical known/custom reasons and never closes cases", async () => {
|
||||
await withFixture(async (browser, base) => {
|
||||
const page = await browser.newPage();
|
||||
await page.goto(`${base}/w/tickets/tickets`);
|
||||
await page.locator(".ticket-list-row").first().waitFor();
|
||||
const response = queryResponse(page, (body) => body.closure_reason === "completed");
|
||||
await search(page, "state:closed reason:completed");
|
||||
assertEquals((await response).request().postDataJSON().states, ["closed"]);
|
||||
await ticketKey(page, "T-4").waitFor();
|
||||
assertEquals(await page.locator(".ticket-list-row").count(), 1);
|
||||
const custom = queryResponse(
|
||||
page,
|
||||
(body) =>
|
||||
(body.closure_reason as { other: string })?.other === "Superseded plan",
|
||||
(body) => (body.closure_reason as { other: string })?.other === "Superseded plan",
|
||||
);
|
||||
await page.getByRole("button", { name: "Apply filters", exact: true })
|
||||
.click();
|
||||
await search(page, 'reason:"Superseded plan"');
|
||||
assertEquals((await custom).request().postDataJSON().closure_reason, {
|
||||
other: "Superseded plan",
|
||||
});
|
||||
await page.getByText("No tickets", { exact: true }).waitFor();
|
||||
assertEquals(
|
||||
(await detail(base, "T-4")).current_closure.reason,
|
||||
"completed",
|
||||
);
|
||||
await page.getByText("No tickets match this query.", { exact: true }).waitFor();
|
||||
assertEquals((await detail(base, "T-4")).current_closure.reason, "completed");
|
||||
const requests = await (await fetch(`${base}/fixture-state`)).json();
|
||||
assert(
|
||||
!requests.some((row: { path: string }) => row.path.endsWith("/case")),
|
||||
assert(!requests.some((row: { path: string }) => row.path.endsWith("/case")));
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test("assignee qualifiers send exact user and runtime-worker identities, not Worker liveness", async () => {
|
||||
await withFixture(async (browser, base) => {
|
||||
const page = await browser.newPage();
|
||||
await page.goto(`${base}/w/tickets/tickets`);
|
||||
await page.locator(".ticket-list-row").first().waitFor();
|
||||
for (
|
||||
const [value, principal] of [
|
||||
["assignee:user/alice", { kind: "user", account_id: "alice" }],
|
||||
["assignee:worker/embedded/running-worker", {
|
||||
kind: "worker",
|
||||
runtime_id: "embedded",
|
||||
worker_id: "running-worker",
|
||||
}],
|
||||
] as const
|
||||
) {
|
||||
const response = queryResponse(
|
||||
page,
|
||||
(body) => JSON.stringify(body.assigned_principal) === JSON.stringify(principal),
|
||||
);
|
||||
await search(page, value);
|
||||
assertEquals((await response).request().postDataJSON().assigned_principal, principal);
|
||||
// All fixture delegations are to the Workspace agent. T-3's Running
|
||||
// Worker observation must not masquerade as an assigned principal.
|
||||
await page.getByText("No tickets match this query.", { exact: true }).waitFor();
|
||||
assertEquals(await page.locator(".ticket-list-row").count(), 0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function boundedGate(promise: Promise<void>) {
|
||||
let timer: number | undefined;
|
||||
try {
|
||||
await Promise.race([
|
||||
promise,
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error("Pagination response gate timed out")), 30_000);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
Deno.test("late old-query pagination cannot append rows after query navigation", async () => {
|
||||
await withFixture(async (browser, base) => {
|
||||
const page = await browser.newPage();
|
||||
await page.goto(`${base}/w/tickets-pages/tickets`);
|
||||
await page.getByRole("button", { name: "Load more tickets", exact: true }).waitFor();
|
||||
const held = Promise.withResolvers<void>();
|
||||
const release = Promise.withResolvers<void>();
|
||||
const finished = Promise.withResolvers<void>();
|
||||
await page.route("**/tickets/query", async (route) => {
|
||||
if (route.request().postDataJSON().cursor !== "30") return await route.continue();
|
||||
const response = await route.fetch();
|
||||
held.resolve();
|
||||
await release.promise;
|
||||
await route.fulfill({ response });
|
||||
finished.resolve();
|
||||
});
|
||||
try {
|
||||
await page.getByRole("button", { name: "Load more tickets", exact: true }).click();
|
||||
await boundedGate(held.promise);
|
||||
const changed = queryResponse(
|
||||
page,
|
||||
(body) => (body.states as string[])?.[0] === "closed" && body.cursor === null,
|
||||
);
|
||||
await search(page, "state:closed");
|
||||
await changed;
|
||||
await ticketKey(page, "T-4").waitFor();
|
||||
release.resolve();
|
||||
await boundedGate(finished.promise);
|
||||
await page.evaluate(
|
||||
"new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))",
|
||||
);
|
||||
assertEquals(await page.locator(".ticket-list-key").allTextContents(), ["T-4"]);
|
||||
assertEquals(
|
||||
await page.getByRole("button", { name: "Load more tickets", exact: true }).count(),
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
release.resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -367,9 +545,7 @@ Deno.test("waiting answers retain a delegation, done reissue retains results, an
|
||||
);
|
||||
const requests = await (await fetch(`${base}/fixture-state`)).json();
|
||||
assert(
|
||||
!requests.some((r: { path: string }) =>
|
||||
/\/(queue|ready|state)$/.test(r.path)
|
||||
),
|
||||
!requests.some((r: { path: string }) => /\/(queue|ready|state)$/.test(r.path)),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -473,9 +649,7 @@ Deno.test("repository-free creation and canonical tag create/attach/rename/delet
|
||||
]);
|
||||
const requests = await (await fetch(`${base}/fixture-state`)).json();
|
||||
assert(
|
||||
!requests.some((row: { path: string }) =>
|
||||
/assignment|\/case$/.test(row.path)
|
||||
),
|
||||
!requests.some((row: { path: string }) => /assignment|\/case$/.test(row.path)),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -501,8 +675,7 @@ Deno.test("ambiguous saved Assign retries exact JSON once, while stale and permi
|
||||
}).count(),
|
||||
0,
|
||||
);
|
||||
const saved =
|
||||
(await detail(base, "T-1")).assignment_tracking[0].assignment_id;
|
||||
const saved = (await detail(base, "T-1")).assignment_tracking[0].assignment_id;
|
||||
const beforeRetry = await (await fetch(`${base}/fixture-state`)).json();
|
||||
const first = beforeRetry.filter((item: { path: string }) =>
|
||||
item.path.endsWith("/assignments/orchestrator")
|
||||
@@ -552,8 +725,7 @@ Deno.test("ambiguous saved Assign retries exact JSON once, while stale and permi
|
||||
},
|
||||
);
|
||||
assert(replacement.ok);
|
||||
expectedAssignment =
|
||||
(await replacement.json()).assignment.assignment_id;
|
||||
expectedAssignment = (await replacement.json()).assignment.assignment_id;
|
||||
}
|
||||
await page.getByRole("button", {
|
||||
name: "Request again (new assignment)",
|
||||
@@ -592,8 +764,7 @@ Deno.test("ambiguous saved Assign retries exact JSON once, while stale and permi
|
||||
|
||||
Deno.test("case and handling matrix renders without overflow in both themes and empty/error/unknown states stay distinct", async () => {
|
||||
await withFixture(async (browser, base) => {
|
||||
const output =
|
||||
new URL("../../../target/web-ux/t743-browser/matrix", import.meta.url)
|
||||
const output = new URL("../../../target/web-ux/t743-browser/matrix", import.meta.url)
|
||||
.pathname;
|
||||
await Deno.mkdir(output, { recursive: true });
|
||||
for (const colorScheme of ["light", "dark"] as const) {
|
||||
@@ -609,12 +780,12 @@ Deno.test("case and handling matrix renders without overflow in both themes and
|
||||
page.on("pageerror", (error) => errors.push(String(error)));
|
||||
for (
|
||||
const [workspace, route, ready] of [
|
||||
["tickets", "tickets", ".ticket-card"],
|
||||
["tickets", "tickets?q=", ".ticket-list-row"],
|
||||
["tickets", "orchestrator", ".orchestrator-assignment"],
|
||||
["tickets-long", "tickets", ".ticket-card"],
|
||||
["tickets-long", "tickets?q=", ".ticket-list-row"],
|
||||
["tickets-empty", "orchestrator", ".orchestrator-assignments"],
|
||||
["tickets-unknown", "orchestrator", ".orchestrator-assignment"],
|
||||
["tickets-error", "tickets", ".ticket-lane-page-error"],
|
||||
["tickets-error", "tickets", "[role=alert]"],
|
||||
["tickets", "tickets/T-1", ".ticket-detail-header"],
|
||||
["tickets", "tickets/T-2", ".ticket-worker-observation strong"],
|
||||
["tickets", "tickets/T-3", ".ticket-worker-observation strong"],
|
||||
@@ -651,10 +822,10 @@ Deno.test("case and handling matrix renders without overflow in both themes and
|
||||
assertEquals(
|
||||
await page.getByRole("button", { name: "Retry", exact: true })
|
||||
.count(),
|
||||
2,
|
||||
1,
|
||||
);
|
||||
assertEquals(
|
||||
await page.getByText("No tickets", { exact: true }).count(),
|
||||
await page.getByText("No tickets match this query.", { exact: true }).count(),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -141,11 +141,11 @@
|
||||
"id": "list",
|
||||
"label": "Member creates a repository-free Ticket",
|
||||
"path": "/w/tickets/tickets",
|
||||
"goal": "Server-filter open and closed cases by canonical tags and current principal",
|
||||
"dataState": "Open+done, open+waiting, inprogress with MR, closed with history",
|
||||
"goal": "Search one server-ordered Ticket list with typed qualifiers; create without a repository",
|
||||
"dataState": "Default open cases; closed reason search and optional creation",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".ticket-kanban"
|
||||
"selector": ".ticket-list-row"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
@@ -169,30 +169,27 @@
|
||||
"fullPage": true
|
||||
},
|
||||
{
|
||||
"id": "last-case",
|
||||
"label": "Closed case and final handling result",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "press",
|
||||
"selector": ".ticket-lane[data-state=closed] .ticket-card",
|
||||
"key": "End"
|
||||
}
|
||||
],
|
||||
"fullPage": true
|
||||
},
|
||||
{
|
||||
"id": "reason-filter",
|
||||
"label": "Current close reason versus historical reason search",
|
||||
"id": "reason-query",
|
||||
"label": "Closed cases with a current completed reason",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "click",
|
||||
"selector": ".ticket-panel-controls button:has-text(\"Cancel\")"
|
||||
},
|
||||
{
|
||||
"action": "fill",
|
||||
"selector": "input[aria-label=\"Search tickets\"]",
|
||||
"value": "state:closed reason:completed"
|
||||
},
|
||||
{
|
||||
"action": "click",
|
||||
"selector": ".ticket-reason-filter summary"
|
||||
"selector": "form[aria-label=\"Search tickets\"] button:has-text(\"Search\")"
|
||||
}
|
||||
],
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".ticket-list-row:has(.ticket-list-key:text-is(\"T-4\"))"
|
||||
},
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
@@ -265,13 +262,13 @@
|
||||
},
|
||||
{
|
||||
"id": "long",
|
||||
"path": "/w/tickets-long/tickets",
|
||||
"path": "/w/tickets-long/tickets?q=",
|
||||
"label": "Long list titles and handling",
|
||||
"goal": "Long list titles and handling",
|
||||
"dataState": "Long titles and multiline results",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".ticket-kanban"
|
||||
"selector": ".ticket-list-row"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
@@ -285,7 +282,7 @@
|
||||
"interaction": [
|
||||
{
|
||||
"action": "press",
|
||||
"selector": ".ticket-lane[data-state=closed] .ticket-card",
|
||||
"selector": ".ticket-list-rows > li:last-child .ticket-list-row",
|
||||
"key": "End"
|
||||
}
|
||||
],
|
||||
@@ -316,10 +313,10 @@
|
||||
"path": "/w/tickets-error/tickets",
|
||||
"label": "Query failure recovery",
|
||||
"goal": "Query failure recovery",
|
||||
"dataState": "503 query, scoped errors and retry",
|
||||
"dataState": "503 query, single list error and retry",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".ticket-lane-page-error"
|
||||
"selector": "[role=alert]"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
@@ -364,6 +361,60 @@
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mixed",
|
||||
"path": "/w/tickets/tickets?q=",
|
||||
"label": "Explicit empty query includes both case states",
|
||||
"goal": "Scan open and closed cases in one server-ordered list",
|
||||
"dataState": "Open+done, open+waiting, inprogress with MR, closed with history",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".ticket-list-row:has(.ticket-list-key:text-is(\"T-4\"))"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Mixed-state flat list",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "list-empty",
|
||||
"path": "/w/tickets-empty/tickets?q=",
|
||||
"label": "No matching Tickets",
|
||||
"goal": "Distinguish an empty list from a failed read",
|
||||
"dataState": "Empty response",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".ticket-list :text-is(\"No tickets match this query.\")"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Empty Ticket list",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "invalid-query",
|
||||
"path": "/w/tickets/tickets?q=state%3Aclose",
|
||||
"label": "Invalid recognized state qualifier",
|
||||
"goal": "Correct a query without issuing a broad fallback read",
|
||||
"dataState": "Rejected close alias",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "[role=alert]"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Query validation error",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"processes": [
|
||||
|
||||
@@ -141,11 +141,11 @@
|
||||
"id": "list",
|
||||
"label": "Member creates a repository-free Ticket",
|
||||
"path": "/w/tickets/tickets",
|
||||
"goal": "Server-filter open and closed cases by canonical tags and current principal",
|
||||
"dataState": "Open+done, open+waiting, inprogress with MR, closed with history",
|
||||
"goal": "Search one server-ordered Ticket list with typed qualifiers; create without a repository",
|
||||
"dataState": "Default open cases; closed reason search and optional creation",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".ticket-kanban"
|
||||
"selector": ".ticket-list-row"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
@@ -169,30 +169,27 @@
|
||||
"fullPage": true
|
||||
},
|
||||
{
|
||||
"id": "last-case",
|
||||
"label": "Closed case and final handling result",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "press",
|
||||
"selector": ".ticket-lane[data-state=closed] .ticket-card",
|
||||
"key": "End"
|
||||
}
|
||||
],
|
||||
"fullPage": true
|
||||
},
|
||||
{
|
||||
"id": "reason-filter",
|
||||
"label": "Current close reason versus historical reason search",
|
||||
"id": "reason-query",
|
||||
"label": "Closed cases with a current completed reason",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "click",
|
||||
"selector": ".ticket-panel-controls button:has-text(\"Cancel\")"
|
||||
},
|
||||
{
|
||||
"action": "fill",
|
||||
"selector": "input[aria-label=\"Search tickets\"]",
|
||||
"value": "state:closed reason:completed"
|
||||
},
|
||||
{
|
||||
"action": "click",
|
||||
"selector": ".ticket-reason-filter summary"
|
||||
"selector": "form[aria-label=\"Search tickets\"] button:has-text(\"Search\")"
|
||||
}
|
||||
],
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".ticket-list-row:has(.ticket-list-key:text-is(\"T-4\"))"
|
||||
},
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
@@ -265,13 +262,13 @@
|
||||
},
|
||||
{
|
||||
"id": "long",
|
||||
"path": "/w/tickets-long/tickets",
|
||||
"path": "/w/tickets-long/tickets?q=",
|
||||
"label": "Long list titles and handling",
|
||||
"goal": "Long list titles and handling",
|
||||
"dataState": "Long titles and multiline results",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".ticket-kanban"
|
||||
"selector": ".ticket-list-row"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
@@ -285,7 +282,7 @@
|
||||
"interaction": [
|
||||
{
|
||||
"action": "press",
|
||||
"selector": ".ticket-lane[data-state=closed] .ticket-card",
|
||||
"selector": ".ticket-list-rows > li:last-child .ticket-list-row",
|
||||
"key": "End"
|
||||
}
|
||||
],
|
||||
@@ -316,10 +313,10 @@
|
||||
"path": "/w/tickets-error/tickets",
|
||||
"label": "Query failure recovery",
|
||||
"goal": "Query failure recovery",
|
||||
"dataState": "503 query, scoped errors and retry",
|
||||
"dataState": "503 query, single list error and retry",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".ticket-lane-page-error"
|
||||
"selector": "[role=alert]"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
@@ -364,6 +361,60 @@
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mixed",
|
||||
"path": "/w/tickets/tickets?q=",
|
||||
"label": "Explicit empty query includes both case states",
|
||||
"goal": "Scan open and closed cases in one server-ordered list",
|
||||
"dataState": "Open+done, open+waiting, inprogress with MR, closed with history",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".ticket-list-row:has(.ticket-list-key:text-is(\"T-4\"))"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Mixed-state flat list",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "list-empty",
|
||||
"path": "/w/tickets-empty/tickets?q=",
|
||||
"label": "No matching Tickets",
|
||||
"goal": "Distinguish an empty list from a failed read",
|
||||
"dataState": "Empty response",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".ticket-list :text-is(\"No tickets match this query.\")"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Empty Ticket list",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "invalid-query",
|
||||
"path": "/w/tickets/tickets?q=state%3Aclose",
|
||||
"label": "Invalid recognized state qualifier",
|
||||
"goal": "Correct a query without issuing a broad fallback read",
|
||||
"dataState": "Rejected close alias",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "[role=alert]"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Query validation error",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"processes": [
|
||||
|
||||
@@ -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 {
|
||||
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(data.workspaceId, "/tickets/query"),
|
||||
{ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(ticketQueryRequest(data.filters, lane.states, lane.page?.next_cursor ?? null)) },
|
||||
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,
|
||||
);
|
||||
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;
|
||||
}
|
||||
// 旧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)}
|
||||
<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)}
|
||||
<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>
|
||||
<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}
|
||||
</li>
|
||||
{/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>
|
||||
</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 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>
|
||||
{: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}
|
||||
</div>
|
||||
</section>
|
||||
{/each}
|
||||
</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 {
|
||||
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