fix: synchronize reused ticket detail routes
This commit is contained in:
@@ -6,7 +6,7 @@
|
|||||||
"dev": "deno run -A npm:vite@7.2.7 dev",
|
"dev": "deno run -A npm:vite@7.2.7 dev",
|
||||||
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
|
"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@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
|
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
|
||||||
"test": "deno test --allow-read=src,test --allow-env=LOG,VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/tasks.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.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-launch.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": "deno test --allow-read=src,test --allow-env=LOG,VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.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-launch.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",
|
||||||
"build": "deno run -A npm:vite@7.2.7 build",
|
"build": "deno run -A npm:vite@7.2.7 build",
|
||||||
"preview": "deno run -A npm:vite@7.2.7 preview"
|
"preview": "deno run -A npm:vite@7.2.7 preview"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -251,7 +251,8 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as
|
|||||||
ticketDetailLoad.includes("/repositories") &&
|
ticketDetailLoad.includes("/repositories") &&
|
||||||
ticketDetailPage.includes('mutate("state", "/state"') &&
|
ticketDetailPage.includes('mutate("state", "/state"') &&
|
||||||
ticketDetailPage.includes("async function queueTicket") &&
|
ticketDetailPage.includes("async function queueTicket") &&
|
||||||
ticketDetailPage.includes("`${ticketPath}/queue`") &&
|
ticketDetailPage.includes("const path = ticketPath") &&
|
||||||
|
ticketDetailPage.includes("`${path}/queue`") &&
|
||||||
!ticketDetailPage.includes("/merge-request/merge") &&
|
!ticketDetailPage.includes("/merge-request/merge") &&
|
||||||
ticketDetailPage.includes("mergeRequest.selector_from") &&
|
ticketDetailPage.includes("mergeRequest.selector_from") &&
|
||||||
ticketDetailPage.includes("mergeRequest.review_status") &&
|
ticketDetailPage.includes("mergeRequest.review_status") &&
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
const initialData = untrack(() => data);
|
const initialData = untrack(() => data);
|
||||||
const loadedTicket = initialData.ticket.data;
|
const loadedTicket = initialData.ticket.data;
|
||||||
if (!loadedTicket) throw new Error(initialData.ticket.error ?? "ticket load failed");
|
if (!loadedTicket) throw new Error(initialData.ticket.error ?? "ticket load failed");
|
||||||
const loadedRepositories = initialData.repositories.data;
|
const loadedRepositories = $derived(data.repositories.data);
|
||||||
|
|
||||||
type QueueOutcome = {
|
type QueueOutcome = {
|
||||||
requested_ticket: string;
|
requested_ticket: string;
|
||||||
@@ -62,6 +62,8 @@
|
|||||||
let manualRuntimeId = $state("");
|
let manualRuntimeId = $state("");
|
||||||
let manualWorkerId = $state("");
|
let manualWorkerId = $state("");
|
||||||
let cancellationReason = $state("");
|
let cancellationReason = $state("");
|
||||||
|
let routeTicketSnapshot = `${initialData.ticketId}:${loadedTicket.item_revision}`;
|
||||||
|
let routeGeneration = 0;
|
||||||
const coderAssignment = $derived(
|
const coderAssignment = $derived(
|
||||||
ticket.assignments.find((assignment) => assignment.role === "coder") ?? null,
|
ticket.assignments.find((assignment) => assignment.role === "coder") ?? null,
|
||||||
);
|
);
|
||||||
@@ -95,13 +97,43 @@
|
|||||||
|
|
||||||
function applyTicket(updatedTicket: TicketDetail): void {
|
function applyTicket(updatedTicket: TicketDetail): void {
|
||||||
ticket = updatedTicket;
|
ticket = updatedTicket;
|
||||||
editTitle = ticket.title;
|
editTitle = updatedTicket.title;
|
||||||
editBody = ticket.body;
|
editBody = updatedTicket.body;
|
||||||
repositoryId = ticket.repository_id ?? "";
|
repositoryId = updatedTicket.repository_id ?? "";
|
||||||
refSelector = ticket.ref_selector ?? "";
|
refSelector = updatedTicket.ref_selector ?? "";
|
||||||
nextState = ticket.state;
|
nextState = updatedTicket.state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetTicketView(updatedTicket: TicketDetail): void {
|
||||||
|
applyTicket(updatedTicket);
|
||||||
|
editing = false;
|
||||||
|
transitionReason = "";
|
||||||
|
threadRole = "comment";
|
||||||
|
threadBody = "";
|
||||||
|
resolution = "";
|
||||||
|
busy = null;
|
||||||
|
errorMessage = null;
|
||||||
|
queueMessage = null;
|
||||||
|
readyOperationKey = null;
|
||||||
|
manualRuntimeId = "";
|
||||||
|
manualWorkerId = "";
|
||||||
|
cancellationReason = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const incomingTicketId = data.ticketId;
|
||||||
|
const incomingTicket = data.ticket.data;
|
||||||
|
if (!incomingTicket) return;
|
||||||
|
const incomingSnapshot = `${incomingTicketId}:${incomingTicket.item_revision}`;
|
||||||
|
|
||||||
|
untrack(() => {
|
||||||
|
if (incomingSnapshot === routeTicketSnapshot) return;
|
||||||
|
routeTicketSnapshot = incomingSnapshot;
|
||||||
|
routeGeneration += 1;
|
||||||
|
resetTicketView(incomingTicket);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
async function mutate(
|
async function mutate(
|
||||||
action: string,
|
action: string,
|
||||||
suffix: string,
|
suffix: string,
|
||||||
@@ -109,40 +141,51 @@
|
|||||||
method = "POST",
|
method = "POST",
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (busy) return false;
|
if (busy) return false;
|
||||||
|
const generation = routeGeneration;
|
||||||
|
const path = `${ticketPath}${suffix}`;
|
||||||
busy = action;
|
busy = action;
|
||||||
errorMessage = null;
|
errorMessage = null;
|
||||||
try {
|
try {
|
||||||
const path = `${ticketPath}${suffix}`;
|
|
||||||
const response = await workspaceApiJsonWithBody<TicketDetail>(path, {
|
const response = await workspaceApiJsonWithBody<TicketDetail>(path, {
|
||||||
method,
|
method,
|
||||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||||
});
|
});
|
||||||
|
if (generation !== routeGeneration) return false;
|
||||||
applyTicket(response);
|
applyTicket(response);
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (generation === routeGeneration) {
|
||||||
errorMessage = error instanceof Error ? error.message : String(error);
|
errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
busy = null;
|
if (generation === routeGeneration) busy = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function queueTicket(): Promise<void> {
|
async function queueTicket(): Promise<void> {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
|
const generation = routeGeneration;
|
||||||
|
const path = ticketPath;
|
||||||
busy = "queue";
|
busy = "queue";
|
||||||
errorMessage = null;
|
errorMessage = null;
|
||||||
queueMessage = null;
|
queueMessage = null;
|
||||||
try {
|
try {
|
||||||
const outcome = await workspaceApiJsonWithBody<QueueOutcome>(
|
const outcome = await workspaceApiJsonWithBody<QueueOutcome>(
|
||||||
`${ticketPath}/queue`,
|
`${path}/queue`,
|
||||||
{ method: "POST", body: JSON.stringify({}) },
|
{ method: "POST", body: JSON.stringify({}) },
|
||||||
);
|
);
|
||||||
|
if (generation !== routeGeneration) return;
|
||||||
|
const updatedTicket = await workspaceApiJson<TicketDetail>(path);
|
||||||
|
if (generation !== routeGeneration) return;
|
||||||
queueMessage = `Queued ${outcome.queued_tickets.length} Ticket(s): ${outcome.queued_tickets.join(", ")}`;
|
queueMessage = `Queued ${outcome.queued_tickets.length} Ticket(s): ${outcome.queued_tickets.join(", ")}`;
|
||||||
applyTicket(await workspaceApiJson<TicketDetail>(ticketPath));
|
applyTicket(updatedTicket);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (generation === routeGeneration) {
|
||||||
errorMessage = error instanceof Error ? error.message : String(error);
|
errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
busy = null;
|
if (generation === routeGeneration) busy = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,11 +195,13 @@
|
|||||||
principal: Record<string, string>,
|
principal: Record<string, string>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
|
const generation = routeGeneration;
|
||||||
|
const path = ticketPath;
|
||||||
busy = action;
|
busy = action;
|
||||||
errorMessage = null;
|
errorMessage = null;
|
||||||
try {
|
try {
|
||||||
await workspaceApiJsonWithBody(
|
await workspaceApiJsonWithBody(
|
||||||
`${ticketPath}/assignments/${role}`,
|
`${path}/assignments/${role}`,
|
||||||
{
|
{
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -166,11 +211,16 @@
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
applyTicket(await workspaceApiJson<TicketDetail>(ticketPath));
|
if (generation !== routeGeneration) return;
|
||||||
|
const updatedTicket = await workspaceApiJson<TicketDetail>(path);
|
||||||
|
if (generation !== routeGeneration) return;
|
||||||
|
applyTicket(updatedTicket);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (generation === routeGeneration) {
|
||||||
errorMessage = error instanceof Error ? error.message : String(error);
|
errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
busy = null;
|
if (generation === routeGeneration) busy = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { assert, assertStringIncludes } from "jsr:@std/assert";
|
||||||
|
|
||||||
|
const pageSource = await Deno.readTextFile(
|
||||||
|
new URL(
|
||||||
|
"../src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte",
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Deno.test("ticket detail synchronizes reused route data", () => {
|
||||||
|
const effectStart = pageSource.indexOf("$effect(() => {");
|
||||||
|
assert(effectStart >= 0, "ticket detail must react to reused route props");
|
||||||
|
|
||||||
|
const effectSource = pageSource.slice(effectStart);
|
||||||
|
for (
|
||||||
|
const token of [
|
||||||
|
"data.ticketId",
|
||||||
|
"data.ticket.data",
|
||||||
|
"incomingTicket.item_revision",
|
||||||
|
"routeGeneration += 1",
|
||||||
|
"resetTicketView(incomingTicket)",
|
||||||
|
]
|
||||||
|
) {
|
||||||
|
assertStringIncludes(effectSource, token);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("ticket detail fences stale mutation responses", () => {
|
||||||
|
for (
|
||||||
|
const operation of [
|
||||||
|
"async function mutate(",
|
||||||
|
"async function queueTicket(",
|
||||||
|
"async function mutateAssignment(",
|
||||||
|
]
|
||||||
|
) {
|
||||||
|
const operationStart = pageSource.indexOf(operation);
|
||||||
|
assert(operationStart >= 0, `missing ${operation}`);
|
||||||
|
const nextOperation = pageSource.indexOf(
|
||||||
|
"\n async function ",
|
||||||
|
operationStart + 1,
|
||||||
|
);
|
||||||
|
const operationSource = pageSource.slice(
|
||||||
|
operationStart,
|
||||||
|
nextOperation === -1 ? undefined : nextOperation,
|
||||||
|
);
|
||||||
|
assertStringIncludes(operationSource, "const generation = routeGeneration");
|
||||||
|
assertStringIncludes(operationSource, "generation !== routeGeneration");
|
||||||
|
assertStringIncludes(operationSource, "generation === routeGeneration");
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user