diff --git a/tests/browser/workspace-library-delete.spec.mjs b/tests/browser/workspace-library-delete.spec.mjs index 154992f5..0891c057 100644 --- a/tests/browser/workspace-library-delete.spec.mjs +++ b/tests/browser/workspace-library-delete.spec.mjs @@ -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); diff --git a/web/js/workspace/library.mjs b/web/js/workspace/library.mjs index 4dae613e..f9190deb 100644 --- a/web/js/workspace/library.mjs +++ b/web/js/workspace/library.mjs @@ -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}); });