mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-10-06 03:37:50 +00:00
Merge pull request #1483 from BradGroux/fix/task-reopen-cleanup-1482
fix: cancel stale task close cleanup on reopen
This commit is contained in:
commit
457e024f6e
3 changed files with 69 additions and 3 deletions
7
docs/design/TASK-REOPEN-LIFECYCLE.md
Normal file
7
docs/design/TASK-REOPEN-LIFECYCLE.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# Task close and reopen lifecycle
|
||||
|
||||
Closing a task retains its selected record briefly for the exit transition. That cleanup belongs to the closed state and is cancelled when any task opens again or the Board unmounts. Both history-driven dismissal and direct close use this lifecycle.
|
||||
|
||||
An earlier unowned timeout could clear a newly opened task after a rapid close/reopen. Reduced-motion navigation made the race particularly visible. Regression coverage closes a task, immediately opens the same or a different task, and confirms an edit saves while the new workspace stays visible. It does not add a delay between close and reopen.
|
||||
|
||||
This lifecycle does not change browser-history entries, task mutation semantics, exit timing, or opener focus restoration. Packaged task-workspace checks exercise the same rapid transition alongside retained section, scroll, pending edit, chat draft, and historical-attempt selection.
|
||||
54
e2e/task-reopen.spec.ts
Normal file
54
e2e/task-reopen.spec.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
import { bypassAuth, seedTestTask, deleteTask, cleanupRoutes } from './helpers/auth';
|
||||
|
||||
for (const target of ['same', 'different'] as const) {
|
||||
test(`the ${target} task remains editable when opened immediately after closing`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await bypassAuth(page);
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
const task = await seedTestTask(page, { title: 'Immediate reopen fixture', status: 'todo' });
|
||||
const id = (task as { id: string }).id;
|
||||
let targetId = id;
|
||||
try {
|
||||
if (target === 'different') {
|
||||
const other = await seedTestTask(page, {
|
||||
title: 'Different reopen fixture',
|
||||
status: 'todo',
|
||||
});
|
||||
targetId = (other as { id: string }).id;
|
||||
}
|
||||
await page.goto('/');
|
||||
const card = page.locator(`[data-task-id="${id}"]`);
|
||||
const detail = page.getByTestId('task-detail-panel');
|
||||
await card.focus();
|
||||
await card.press('Enter');
|
||||
await expect(detail).toBeVisible();
|
||||
await detail.getByRole('button', { name: 'Close task workspace', exact: true }).click();
|
||||
await expect(detail).toHaveCount(0);
|
||||
const nextCard = page.locator(`[data-task-id="${targetId}"]`);
|
||||
await nextCard.focus();
|
||||
await nextCard.press('Enter');
|
||||
const title = detail.getByRole('textbox', { name: 'Task title', exact: true });
|
||||
await expect(title).toHaveValue(
|
||||
target === 'same' ? 'Immediate reopen fixture' : 'Different reopen fixture'
|
||||
);
|
||||
const [saved] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
new URL(response.url()).pathname === `/api/tasks/${targetId}` &&
|
||||
response.request().method() === 'PATCH',
|
||||
{ timeout: 5000 }
|
||||
),
|
||||
title.fill('Reopened task stays editable'),
|
||||
]);
|
||||
expect(saved.ok()).toBeTruthy();
|
||||
await expect(detail).toBeVisible();
|
||||
await expect(title).toHaveValue('Reopened task stays editable');
|
||||
} finally {
|
||||
if (targetId !== id) await deleteTask(page, targetId);
|
||||
await deleteTask(page, id);
|
||||
await cleanupRoutes(page);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -296,12 +296,19 @@ export function KanbanBoard() {
|
|||
const handlePopState = () => {
|
||||
if (!detailOpen || taskIdFromHistory() === selectedTask?.id) return;
|
||||
setDetailOpen(false);
|
||||
setTimeout(() => setSelectedTask(null), 200);
|
||||
};
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
return () => window.removeEventListener('popstate', handlePopState);
|
||||
}, [detailOpen, selectedTask?.id]);
|
||||
|
||||
// Retain the closing task for its exit transition, but cancel that cleanup
|
||||
// when a task is reopened before the transition finishes.
|
||||
useEffect(() => {
|
||||
if (detailOpen || !selectedTask) return;
|
||||
const timer = window.setTimeout(() => setSelectedTask(null), 200);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [detailOpen, selectedTask]);
|
||||
|
||||
// Apply the configured default saved view only when the current URL has no board filters.
|
||||
useEffect(() => {
|
||||
if (defaultSavedViewAppliedRef.current || isPlaceholderData) return;
|
||||
|
|
@ -558,8 +565,6 @@ export function KanbanBoard() {
|
|||
}
|
||||
setDetailOpen(open);
|
||||
if (!open) {
|
||||
// Small delay to allow animation to complete
|
||||
setTimeout(() => setSelectedTask(null), 200);
|
||||
returnFromTask();
|
||||
}
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue