fix(library): hold Edit metadata while a prompt's page delete runs (#481)

While the page's Delete… review or its write runs, Edit metadata is
aria-disabled and its click ignored, so no editor save can race the delete.
Adds tests for a failed review, a write answered after the page was left
(no toast, no jump, the shelf reads again) and heading focus after an
archive.
This commit is contained in:
Vito Sansevero
2026-10-29 13:22:13 -07:00
parent 6fdcbacfc1
commit d0ffcccc23
2 changed files with 47 additions and 5 deletions
@@ -197,6 +197,7 @@ test('Delete prompt… on a prompt\'s page archives it by default, and deletes i
await expect(toast(page)).toHaveText('Archived 1 prompt.');
// The page stays and shows its Archived notice.
await expect(page.locator('.library-archived-notice h2')).toHaveText('Archived');
await expect(page.locator('#workspace-main h1')).toBeFocused();
await page.locator('.library-archived-notice').getByRole('button',{name:'Restore this prompt to the Prompt library'}).click();
await expect(toast(page)).toHaveText('Restored 1 prompt to the Prompt library.');
// Delete permanently: back to the Prompt library, the prompt gone.
@@ -226,14 +227,16 @@ test('the page\'s delete holds one review at a time, Escape returns focus, and a
await page.route(`**${PREVIEW}`,async item=>{await held;await item.fallback();});
await page.goto(`/prompt_manager/workspace#/library/${dual.id}`);
await editMetadata(page).click();await editor(page).getByRole('button',{name:'Delete prompt…',exact:true}).click();
// A second Delete prompt… while the first preview is held starts nothing.
await editMetadata(page).click();await editor(page).getByRole('button',{name:'Delete prompt…',exact:true}).click();
// While the preview is held, Edit metadata is held too: no second review and no save can race the delete.
await expect(editMetadata(page)).toHaveAttribute('aria-disabled','true');
await editMetadata(page).click({force:true});await expect(editor(page)).toHaveCount(0);
release();
await expect(review(page)).toBeVisible();
expect(previews).toHaveLength(1);
expect(previews[0].expected_epoch).toMatch(/\S/);
await page.keyboard.press('Escape');
await expect(review(page)).toHaveCount(0);await expect(editMetadata(page)).toBeFocused();
await expect(editMetadata(page)).not.toHaveAttribute('aria-disabled');
await page.unroute(`**${PREVIEW}`);
// Archived: its review has nothing to change and only closes.
await editMetadata(page).click();await editor(page).getByRole('button',{name:'Delete prompt…',exact:true}).click();
@@ -244,6 +247,38 @@ test('the page\'s delete holds one review at a time, Escape returns focus, and a
await expect(review(page).getByRole('button',{name:/^(Delete|Archive)/})).toHaveCount(0);
});
test('a failed review on the prompt\'s page says so, changes nothing and releases Edit metadata (#481)',async({page,request})=>{
const dual=byText(await items(request),'Dual role portrait');
const writes=recordPosts(page,DELETE);
await page.route(`**${PREVIEW}`,item=>item.fulfill({status:500,contentType:'application/json',body:JSON.stringify({success:false,code:'review-failed',error:'x'})}),{times:1});
await page.goto(`/prompt_manager/workspace#/library/${dual.id}`);
await editMetadata(page).click();await editor(page).getByRole('button',{name:'Delete prompt…',exact:true}).click();
await expect(toast(page)).toHaveText('The selected prompts could not be reviewed. Try again.');
await expect(review(page)).toHaveCount(0);await expect(editMetadata(page)).not.toHaveAttribute('aria-disabled');
expect(writes).toEqual([]);
await editMetadata(page).click();await expect(editor(page)).toBeVisible();
});
test('a delete answered after the page was left invalidates the shelf without a toast or a jump (#481)',async({page,request})=>{
const dual=byText(await items(request),'Dual role portrait');
let release;const held=new Promise(resolve=>{release=resolve;});
let answered;const done=new Promise(resolve=>{answered=resolve;});
await page.route(`**${DELETE}`,async item=>{await held;const response=await item.fetch();await item.fulfill({response});answered();});
await deleteFromPage(page,dual.id);
await review(page).getByRole('button',{name:'Archive 1 prompt',exact:true}).click();
await page.evaluate(()=>{window.location.hash='#/overview';});
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
// Every toast text shown from here on is recorded, so one shown and hidden again still counts.
await page.evaluate(()=>{window.seenToasts=[];new MutationObserver(()=>{for(const node of document.querySelectorAll('.toast'))
if(node.textContent.trim())window.seenToasts.push(node.textContent.trim());}).observe(document.body,{subtree:true,childList:true,characterData:true});});
release();await done;
// The answer lands on Overview: no navigation; the shelf reads again when opened; and no toast was ever shown.
await page.evaluate(()=>{window.location.hash='#/library';});
await expect(card(page,dual.id)).toHaveCount(0);
expect((await items(request,'archived')).map(item=>item.id)).toEqual([dual.id]);
expect(await page.evaluate(()=>window.seenToasts)).toEqual([]);
});
test('a restore since the prompt\'s page loaded is refused and the page reads the Library again (#481)',async({page,request})=>{
const dual=byText(await items(request),'Dual role portrait');
await armUnseenRestores(page);
+10 -3
View File
@@ -500,14 +500,20 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
onDeletePrompt:()=>deletePrompt()});
/** #481: this prompt through the Library's Delete… review; a delete returns to the shelf, an archive shows its notice. */
let deleting=false;
// While the review or its write runs, Edit metadata is held (as the selection bar holds its actions), so no save
// can race the delete; aria-disabled keeps it focusable for the return.
function holdEdit(held) {
deleting=held;
if(held) edit.setAttribute('aria-disabled','true');else edit.removeAttribute('aria-disabled');
}
function deletePrompt() {
const shown=detail;if(deleting || !shown?.positive) return;
// One review at a time, as the selection bar holds itself (a second press during the preview starts nothing).
deleting=true;
holdEdit(true);
reviewPromptDelete({api,scope,trigger:edit,ids:[shown.positive.id],epoch:shown.library_epoch,
titleOf:()=>displayTitle(shown.positive),onDismiss:()=>{deleting=false;if(edit.isConnected) edit.focus({preventScroll:true});},
titleOf:()=>displayTitle(shown.positive),onDismiss:()=>{holdEdit(false);if(edit.isConnected) edit.focus({preventScroll:true});},
onSettled:(outcome,result,failure)=>{
deleting=false;
holdEdit(false);
if(outcome.committed) invalidateLibraryAfterWrite({kind:'bulk-delete',receipt:result ?? {phase:'unconfirmed'}});
// A left page still invalidates what it cached, but says nothing (as the bar's write).
if(scope.signal.aborted) return;
@@ -520,6 +526,7 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
}
scope.listen(back,'click',()=>{window.location.hash='#/library';});
scope.listen(edit,'click',()=>{
if(deleting) return;
const draft=metadataDrafts.get(positiveId);metadataDrafts.delete(positiveId);
editor.open({mode:'metadata',positiveId,pairId:current?.activePairId() ?? null,trigger:edit,draft:draft || null});
});