diff --git a/TESTING.md b/TESTING.md index 7168392d..932ed058 100644 --- a/TESTING.md +++ b/TESTING.md @@ -2740,6 +2740,23 @@ with its own negative, a self-pair, each drift, validation); `tests/test_library copy, the server after, archive-only, reset to Archive) and `library-delete-concept.spec.mjs`. A Python change: ComfyUI needs a restart. +### Delete on a prompt's own page (#481) + +**D161** (beyond the concept, coordinator ruling (A) 2026-10-29, open to the maintainer at the live test): the recipe +editor (Edit metadata, the concept's app.js 976 row "Create text revision", "Archive…", Cancel, Save) reads "Create +text revision", **"Archive variant…"** (the selected negative variant's archive, #235, which used the concept's +"Archive…" label) and **"Delete prompt…"** (danger), then Cancel and Save. "Delete prompt…" closes the editor and runs +D152/D160's Delete… review for that one prompt (`reviewPromptDelete`: the same preview, review with the saved-pair +choice, backup and write, fenced by the page's `library_epoch`). Cancel changes nothing and returns focus to Edit +metadata. A delete toasts and opens the Prompt library; an archive (or a drift) toasts and reads the page again, so an +archived prompt shows its Archived notice. The button is disabled while the editor saves or after a restore. + +The concept comparison applies D161 in `mapConcept` (`tests/browser/library-editor-canonical-comparison.mjs`): the +concept's danger "Archive…" maps to "Archive variant…" followed by a "Delete prompt…" with the same geometry and tokens. + +Tests: `tests/browser/workspace-library-delete.spec.mjs` (#481 cases), and `workspace-library-editor.spec.mjs` (the +hand-off and the comparisons) and `library-editor-visual.spec.mjs` with the renamed button. JS only: no restart. + ### No 1–5 prompt rating in the workspace (#447) **D153** (maintainer ruling 2026-10-28: the star is only for favorites; the concept keeps diff --git a/tests/browser/library-editor-canonical-comparison.mjs b/tests/browser/library-editor-canonical-comparison.mjs index efec7ec6..14487ada 100644 --- a/tests/browser/library-editor-canonical-comparison.mjs +++ b/tests/browser/library-editor-canonical-comparison.mjs @@ -26,9 +26,15 @@ export function inventoryOf(dialog) { }; } // Documented differences only: prototype Save label, prototype demo note, data-dependent values, and D153 (#447): the -// concept's "Rating" select is not drawn. +// concept's "Rating" select is not drawn. D161 (#481): the concept's one danger "Archive…" becomes "Archive variant…" and +// is followed by the prompt's own "Delete prompt…", drawn as the same danger button. +function mapButton(item) { + if(item.text==='Save in prototype') return [{...item,text:'Save'}]; + if(item.text==='Archive…') return [{...item,text:'Archive variant…'},{...item,text:'Delete prompt…'}]; + return [item]; +} export function mapConcept(reference) { - return {...reference,demo:false,fields:reference.fields.filter(item=>item.label!=='Rating'),actions:{...reference.actions,buttons:reference.actions.buttons.map(item=>item.text==='Save in prototype'?{...item,text:'Save'}:item)}}; + return {...reference,demo:false,fields:reference.fields.filter(item=>item.label!=='Rating'),actions:{...reference.actions,buttons:reference.actions.buttons.flatMap(mapButton)}}; } // Flatten an inventory to path->value so a comparison reports exactly which leaves differ. diff --git a/tests/browser/library-editor-visual.spec.mjs b/tests/browser/library-editor-visual.spec.mjs index 8b92c225..7a0d62b4 100644 --- a/tests/browser/library-editor-visual.spec.mjs +++ b/tests/browser/library-editor-visual.spec.mjs @@ -24,7 +24,7 @@ for(const [width,height,theme] of [[1600,1100,'dark'],[390,844,'light']])test(`e await variants(page).getByRole('button',{name:'Select negative variant 2',exact:true}).click(); await page.getByRole('button',{name:'Edit metadata',exact:true}).click();await expect(editor(page)).toBeVisible(); await expect(editor(page).getByRole('button',{name:'Save',exact:true})).toBeEnabled(); - await editor(page).getByRole('button',{name:'Archive…',exact:true}).click(); + await editor(page).getByRole('button',{name:'Archive variant…',exact:true}).click(); await expect(editor(page).getByRole('button',{name:'Archive',exact:true})).toBeEnabled();await shot('editor-archive'); let release;const held=new Promise(resolve=>{release=resolve;}); diff --git a/tests/browser/workspace-library-delete.spec.mjs b/tests/browser/workspace-library-delete.spec.mjs index 306e42a6..154992f5 100644 --- a/tests/browser/workspace-library-delete.spec.mjs +++ b/tests/browser/workspace-library-delete.spec.mjs @@ -175,6 +175,89 @@ test('the choice resets to Archive on every Delete…, and a selection with no p await expect(choice(page)).toHaveCount(0); }); +// #481 (D161): a prompt's own page deletes it from Edit metadata (the concept's recipe editor, app.js 976), through the +// same review: "Delete prompt…" beside "Archive variant…" (the variant's archive, renamed so the two cannot be confused). +const editMetadata=page=>page.locator('#workspace-main .heading-actions').getByRole('button',{name:'Edit metadata',exact:true}); +const editor=page=>page.locator('dialog.library-editor[open]'); +async function deleteFromPage(page,id){ + await page.goto(`/prompt_manager/workspace#/library/${id}`); + await editMetadata(page).click(); + await expect(editor(page).locator('.dialog-actions button')).toHaveText(['Create text revision','Archive variant…','Delete prompt…','Cancel','Save']); + await expect(editor(page).getByRole('button',{name:'Delete prompt…',exact:true})).toHaveClass(/\bdanger\b/); + await editor(page).getByRole('button',{name:'Delete prompt…',exact:true}).click(); + await expect(editor(page)).toHaveCount(0);await expect(review(page)).toBeVisible(); +} +test('Delete prompt… on a prompt\'s page archives it by default, and deletes it permanently when chosen (#481)',async({page,request})=>{ + const dual=byText(await items(request),'Dual role portrait'); + const previews=recordPosts(page,PREVIEW); + await deleteFromPage(page,dual.id); + expect(previews.map(body=>body.prompt_ids)).toEqual([[dual.id]]); + await expect(choice(page).getByRole('radio',{name:'Archive (hidden, restorable)'})).toBeChecked(); + await review(page).getByRole('button',{name:'Archive 1 prompt',exact:true}).click(); + 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 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. + await editMetadata(page).click();await editor(page).getByRole('button',{name:'Delete prompt…',exact:true}).click(); + await choice(page).getByRole('radio',{name:'Delete permanently, with their saved pairs'}).check(); + await review(page).getByRole('button',{name:'Delete 1 prompt',exact:true}).click(); + await expect(toast(page)).toHaveText(/^Deleted 1 prompt\. Removed 1 saved pair\. Safety backup saved: /); + await expect(page).toHaveURL(/#\/library$/); + await expect(card(page,dual.id)).toHaveCount(0); + expect(byText(await items(request),'Dual role portrait')).toBeUndefined(); +}); + +test('Cancel on the page\'s review changes nothing and returns focus to Edit metadata (#481)',async({page,request})=>{ + const dual=byText(await items(request),'Dual role portrait'); + const writes=recordPosts(page,DELETE); + await deleteFromPage(page,dual.id); + await review(page).getByRole('button',{name:'Cancel',exact:true}).click(); + await expect(review(page)).toHaveCount(0);await expect(editMetadata(page)).toBeFocused(); + expect(writes).toEqual([]); + expect(byText(await items(request),'Dual role portrait')?.id).toBe(dual.id); +}); + +test('the page\'s delete holds one review at a time, Escape returns focus, and an archived page\'s review is close-only (#481)',async({page,request})=>{ + const dual=byText(await items(request),'Dual role portrait'); + const previews=recordPosts(page,PREVIEW); + let release;const held=new Promise(resolve=>{release=resolve;}); + 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(); + 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 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(); + await review(page).getByRole('button',{name:'Archive 1 prompt',exact:true}).click(); + await expect(page.locator('.library-archived-notice')).toBeVisible(); + await editMetadata(page).click();await editor(page).getByRole('button',{name:'Delete prompt…',exact:true}).click(); + await expect(review(page).locator('p.small').last()).toHaveText('None of the selected prompts can be deleted or archived.'); + await expect(review(page).getByRole('button',{name:/^(Delete|Archive)/})).toHaveCount(0); +}); + +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); + await page.goto(`/prompt_manager/workspace#/library/${dual.id}`); + await editMetadata(page).click();await editor(page).getByRole('button',{name:'Delete prompt…',exact:true}).click(); + const backup=await request.get('/prompt_manager/backup');await expect(backup).toBeOK();const buffer=await backup.body(); + const restore=await unseenRestore(page,()=>request.post('/prompt_manager/restore',{multipart:{database_file:{name:'library.db',mimeType:'application/octet-stream',buffer}}})); + await expect(restore).toBeOK(); + await review(page).getByRole('button',{name:'Archive 1 prompt',exact:true}).click(); + await expect(toast(page)).toHaveText('The library was restored or moved since this page loaded. Nothing was deleted or archived.'); + await expect(page.locator('#workspace-main h1')).toBeFocused(); + expect((await items(request,'archived')).map(item=>item.id)).toEqual([]); +}); + test('an archived prompt\'s page says so and restores it from its notice',async({page,request})=>{ const cat=byText(await items(request),'Cat portrait'); const epoch=(await(await request.get('/prompt_manager/library/options')).json()).library_epoch; diff --git a/tests/browser/workspace-library-editor.spec.mjs b/tests/browser/workspace-library-editor.spec.mjs index d646291e..b1edb025 100644 --- a/tests/browser/workspace-library-editor.spec.mjs +++ b/tests/browser/workspace-library-editor.spec.mjs @@ -8,7 +8,7 @@ import {inventoryOf,mapConcept,diffInventory} from './library-editor-canonical-c // Dialog-level contracts mount the editor through a page import; the user flows below use the real entry actions. test.use({locale:'en-US',timezoneId:'UTC',reducedMotion:'reduce'}); const workspace='/prompt_manager/workspace'; -const ACTIONS={new:['Cancel','Save'],metadata:['Create text revision','Archive…','Cancel','Save'],revision:['Cancel','Save'],negative:['Cancel','Save']}; +const ACTIONS={new:['Cancel','Save'],metadata:['Create text revision','Archive variant…','Delete prompt…','Cancel','Save'],revision:['Cancel','Save'],negative:['Cancel','Save']}; // D153 (#447): no Rating field; the fixture's positive stays stored with rating 4 through every workspace save. const LABELS=['Title','Positive prompt','Negative prompt (optional)','Category','Tags, separated by commas','Notes']; const UNAPPLIED='Saved as a new negative variant of an existing prompt. The title, category, tags and notes you entered were not applied.'; @@ -27,13 +27,15 @@ async function mountEditor(page,theme) { if(theme)await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme); await page.evaluate(async()=>{ const trigger=document.createElement('button');trigger.type='button';trigger.id='editor-trigger';trigger.textContent='Open editor';document.body.append(trigger); - window.editorEvents={committed:[],reconciled:[]}; + window.editorEvents={committed:[],reconciled:[],deletePrompt:[]}; try { const {createLibraryEditor,showWorkspaceToast}=await import('/prompt_manager/js/workspace/library-editor.mjs'); const {createScope}=await import('/prompt_manager/js/shared/toolkit/core.js'); const scope=createScope(); const editor=createLibraryEditor({api:window.ApiClient.createApiClient(),parentScope:scope, - onCommitted:commit=>window.editorEvents.committed.push(commit),onReconciled:result=>window.editorEvents.reconciled.push(result)}); + onCommitted:commit=>window.editorEvents.committed.push(commit),onReconciled:result=>window.editorEvents.reconciled.push(result), + // As the prompt's page wires it (#481); the review itself is covered in workspace-library-delete.spec.mjs. + onDeletePrompt:request=>window.editorEvents.deletePrompt.push(request.positiveId)}); window.editorHarness={scope,editor,showWorkspaceToast}; } catch(error) {window.editorHarness={missing:String(error)};} }); @@ -52,7 +54,7 @@ const pairRead=async(request,id)=>(await(await request.get(`/prompt_manager/pair test('edit recipe metadata shows the concept fields with the selected saved negative read-only',async({page})=>{ const f=page.fixture;await mountEditor(page); - const dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id}); + let dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id}); await expect(dialog.getByRole('heading',{name:'Edit recipe metadata',exact:true})).toBeVisible(); expect(await dialog.evaluate(node=>node.matches(':modal'))).toBe(true); await expect(button(dialog,'Close dialog')).toBeFocused();await ready(dialog); @@ -73,9 +75,14 @@ test('edit recipe metadata shows the concept fields with the selected saved nega await expect(dialog.locator('[placeholder]')).toHaveCount(0); await expect(dialog.locator('.demo-note')).toHaveCount(0); expect(await actionNames(dialog)).toEqual(ACTIONS.metadata); - await expect(button(dialog,'Archive…')).toHaveClass(/\bdanger\b/);await expect(button(dialog,'Save')).toHaveClass(/\bca-primary\b/); + await expect(button(dialog,'Archive variant…')).toHaveClass(/\bdanger\b/);await expect(button(dialog,'Save')).toHaveClass(/\bca-primary\b/); await page.keyboard.press('Escape');await expect(dialog).toHaveCount(0); await expect(page.locator('#editor-trigger')).toBeFocused();expect(page.writes).toHaveLength(0); + // D161 (#481): Delete prompt… closes the editor first and hands the prompt to the page's delete review. + dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog); + await expect(button(dialog,'Delete prompt…')).toHaveClass(/\bdanger\b/);await button(dialog,'Delete prompt…').click(); + await expect(dialog).toHaveCount(0); + expect(await page.evaluate(()=>window.editorEvents.deletePrompt)).toEqual([f.positive_id]);expect(page.writes).toHaveLength(0); }); test('editor modes persist through reload with selected saved negative',async({page,request})=>{ @@ -144,13 +151,13 @@ test('editor modes persist through reload with selected saved negative',async({p test('archive confirms the selected pair with fresh counts and writes only on confirm',async({page,request})=>{ const f=page.fixture;await mountEditor(page); let dialog=await openEditor(page,{mode:'metadata',positiveId:f.standalone_id,pairId:null});await ready(dialog); - const archive=button(dialog,'Archive…');await expect(archive).toBeDisabled(); + const archive=button(dialog,'Archive variant…');await expect(archive).toBeDisabled(); await expect(archive).toHaveAccessibleDescription('Archive needs a selected saved negative variant.'); await expect(note(dialog)).toHaveText('Archive needs a selected saved negative variant.'); await button(dialog,'Cancel').click();await expect(dialog).toHaveCount(0);await expect(page.locator('#editor-trigger')).toBeFocused(); dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog); - await button(dialog,'Archive…').click(); + await button(dialog,'Archive variant…').click(); await expect(dialog.getByRole('heading',{name:'Archive negative variant',exact:true})).toBeVisible(); await expect(button(dialog,'Close dialog')).toBeFocused(); const block=dialog.getByRole('region',{name:'Negative prompt',exact:true}); @@ -163,7 +170,7 @@ test('archive confirms the selected pair with fresh counts and writes only on co expect((await pairRead(request,f.pair_id)).archived).toBe(false); dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog); - await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled(); + await button(dialog,'Archive variant…').click();await expect(button(dialog,'Archive')).toBeEnabled(); await button(dialog,'Archive').click();await expect(dialog).toHaveCount(0); expect(page.writes).toEqual([{method:'PATCH',url:`/prompt_manager/pairs/${f.pair_id}`,body:{archived:true}}]); expect((await pairRead(request,f.pair_id)).archived).toBe(true); @@ -178,7 +185,7 @@ test('a failed archive keeps focus on the Archive confirmation inside the dialog failed=true;return route.fulfill({status:400,contentType:'application/json',body:'{"success":false,"error":"Cannot archive.","code":null}'}); }); const dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog); - await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled(); + await button(dialog,'Archive variant…').click();await expect(button(dialog,'Archive')).toBeEnabled(); await button(dialog,'Archive').click(); await expect(note(dialog)).toContainText('Cannot archive.');expect(page.writes).toHaveLength(1); await expect(button(dialog,'Archive')).toBeFocused(); @@ -192,7 +199,7 @@ test('an unconfirmed archive moves focus to Retry instead of dropping it out of await route.continue(); }); const dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog); - await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled(); + await button(dialog,'Archive variant…').click();await expect(button(dialog,'Archive')).toBeEnabled(); await button(dialog,'Archive').click(); await expect(note(dialog)).toHaveText('Archive could not be confirmed. Retry sends the same archive once.'); await expect(button(dialog,'Archive')).toBeDisabled();await expect(button(dialog,'Retry')).toBeFocused(); @@ -209,7 +216,7 @@ test('an archive conflict offers an explicit Reload that re-reads and never repl conflicted=true;return route.fulfill({status:409,contentType:'application/json',body:'{"success":false,"error":"changed","code":"metadata-conflict"}'}); }); const dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog); - await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled(); + await button(dialog,'Archive variant…').click();await expect(button(dialog,'Archive')).toBeEnabled(); await button(dialog,'Archive').click(); await expect(note(dialog)).toHaveText('This negative variant changed since it was loaded. Reload it and review it before archiving.'); await expect(button(dialog,'Archive')).toBeDisabled();expect(page.writes).toHaveLength(1); @@ -237,7 +244,7 @@ test('cancel and busy submit have honest write counts',async({page})=>{ dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog); await field(dialog,'Notes').fill('busy save');await button(dialog,'Save').click(); await expect(note(dialog)).toHaveText('Saving… The dialog stays open until the server answers.'); - for(const name of ['Save','Cancel','Close dialog','Create text revision','Archive…'])await expect(button(dialog,name)).toBeDisabled(); + for(const name of ['Save','Cancel','Close dialog','Create text revision','Archive variant…','Delete prompt…'])await expect(button(dialog,name)).toBeDisabled(); await field(dialog,'Title').press('Enter');await page.keyboard.press('Escape'); await expect(dialog).toBeVisible();expect(page.writes).toHaveLength(1); release();await expect(dialog).toHaveCount(0);expect(page.writes).toHaveLength(1); @@ -319,15 +326,15 @@ test('edge states keep drafts, explain disabled archive and clean up on dispose' dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.archived_pair_id});await ready(dialog); await expect(field(dialog,'Negative prompt (optional)')).toHaveValue(f.archived_negative); - await expect(button(dialog,'Archive…')).toBeDisabled();await expect(note(dialog)).toHaveText('This negative variant is already archived.'); - await expect(button(dialog,'Archive…')).toHaveAccessibleDescription('This negative variant is already archived.'); + await expect(button(dialog,'Archive variant…')).toBeDisabled();await expect(note(dialog)).toHaveText('This negative variant is already archived.'); + await expect(button(dialog,'Archive variant…')).toHaveAccessibleDescription('This negative variant is already archived.'); await button(dialog,'Cancel').click(); let failed=true; await page.route(`**/prompt_manager/pairs/${f.empty_pair_id}`,route=>{if(!failed&&route.request().method()==='GET'){failed=true;return route.fulfill({status:500,contentType:'application/json',body:'{"success":false,"error":"down"}'});}return route.continue();}); dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.empty_pair_id});await ready(dialog); await expect(field(dialog,'Negative prompt (optional)')).toHaveValue(''); - failed=false;await button(dialog,'Archive…').click(); + failed=false;await button(dialog,'Archive variant…').click(); await expect(note(dialog)).toHaveText('Linked images and recorded runs could not be read. Nothing was archived.'); await expect(button(dialog,'Archive')).toBeDisabled(); await expect(dialog.getByRole('region',{name:'Negative prompt',exact:true})).toContainText('Empty negative prompt'); @@ -347,12 +354,12 @@ test('the archive confirmation does not inherit a failed or invalid Edit save me let dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog); await field(dialog,'Notes').fill('Rejected notes');await button(dialog,'Save').click(); await expect(note(dialog)).toHaveText('Not saved: Notes are too long. Your entries are kept.'); - await button(dialog,'Archive…').click();await expect(dialog).toContainText('1 image and 2 recorded runs stay linked.'); + await button(dialog,'Archive variant…').click();await expect(dialog).toContainText('1 image and 2 recorded runs stay linked.'); await expect(note(dialog)).toBeHidden();await expect(button(dialog,'Archive')).toBeEnabled(); await button(dialog,'Cancel').click();await expect(dialog).toHaveCount(0); dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog); await field(dialog,'Title').fill(' ');await button(dialog,'Save').click();await expect(note(dialog)).toHaveText('Title is required'); - await button(dialog,'Archive…').click();await expect(dialog).toContainText('1 image and 2 recorded runs stay linked.'); + await button(dialog,'Archive variant…').click();await expect(dialog).toContainText('1 image and 2 recorded runs stay linked.'); await expect(note(dialog)).toBeHidden();await expect(button(dialog,'Archive')).toBeEnabled(); expect(page.writes).toHaveLength(1); }); @@ -638,14 +645,14 @@ test('restored storage after a confirmed save or before an archive offers an exp await page.getByRole('button',{name:'Edit metadata',exact:true}).click();let dialog=editorDialog(page);await ready(dialog); await field(dialog,'Notes').fill('Lost to a restore');await button(dialog,'Save').click(); await expect(note(dialog)).toHaveText('Saved; Library refresh failed because prompt storage was restored. Reload the Library to see current data.'); - expect(await actionNames(dialog)).toEqual(['Reload Library','Create text revision','Archive…','Cancel','Save']); - for(const name of ['Create text revision','Archive…'])await expect(button(dialog,name)).toBeDisabled(); + expect(await actionNames(dialog)).toEqual(['Reload Library','Create text revision','Archive variant…','Delete prompt…','Cancel','Save']); + for(const name of ['Create text revision','Archive variant…','Delete prompt…'])await expect(button(dialog,name)).toBeDisabled(); await button(dialog,'Reload Library').click();await expect(dialog).toHaveCount(0); await expect(variantsPanel(page)).toBeVisible();await expect(page.locator('#workspace-main h1')).toHaveText(f.metadata.title); await page.unroute(`**/prompt_manager/library/${f.positive_id}`);expect(page.writes).toHaveLength(1); await selectPair(page,'Select negative variant 2'); await page.getByRole('button',{name:'Edit metadata',exact:true}).click();dialog=editorDialog(page);await ready(dialog); - await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled(); + await button(dialog,'Archive variant…').click();await expect(button(dialog,'Archive')).toBeEnabled(); await expect(await restore()).toBeOK();await button(dialog,'Archive').click(); await expect(note(dialog)).toHaveText('Prompt storage was restored since this dialog opened. Nothing was archived.'); expect(await actionNames(dialog)).toEqual(['Reload Library','Cancel','Archive']);expect(page.writes).toHaveLength(1); @@ -662,10 +669,10 @@ test('restored storage before an Edit or Add negative save offers only the Libra await expect(await restore()).toBeOK(); await field(dialog,'Notes').fill('Typed before the restore');await button(dialog,'Save').click(); await expect(note(dialog)).toHaveText(`Prompt storage was restored since this dialog opened. Nothing was saved. ${DISCARDS}`); - expect(await actionNames(dialog)).toEqual(['Reload Library','Create text revision','Archive…','Cancel','Save']); + expect(await actionNames(dialog)).toEqual(['Reload Library','Create text revision','Archive variant…','Delete prompt…','Cancel','Save']); await expect(button(dialog,'Reload Library state')).toHaveCount(0);await expect(button(dialog,'Save')).toBeDisabled(); // Revise and archive would re-read the old numeric IDs, which may now name another prompt. - for(const name of ['Create text revision','Archive…'])await expect(button(dialog,name)).toBeDisabled(); + for(const name of ['Create text revision','Archive variant…','Delete prompt…'])await expect(button(dialog,name)).toBeDisabled(); await expect(button(dialog,'Reload Library')).toBeFocused(); expect(page.writes).toHaveLength(1);// the refused save await button(dialog,'Reload Library').click();await expect(dialog).toHaveCount(0);await expect(variantsPanel(page)).toBeVisible(); @@ -732,7 +739,7 @@ test('archive hides a pair, Show archived finds it and Restore keeps it selected const before=await evidence();expect(before.counts).toEqual({images:1,runs:2}); await openHistory(page,f.positive_id);await selectPair(page,'Select negative variant 2'); await page.getByRole('button',{name:'Edit metadata',exact:true}).click();const dialog=editorDialog(page);await ready(dialog); - await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();await button(dialog,'Archive').click();await expect(dialog).toHaveCount(0); + await button(dialog,'Archive variant…').click();await expect(button(dialog,'Archive')).toBeEnabled();await button(dialog,'Archive').click();await expect(dialog).toHaveCount(0); await expect(toast(page)).toHaveText('Archived. Find it under Show archived in Negative variants.');await expect(page.locator('#workspace-main h1')).toBeFocused(); await expect(variantsPanel(page).locator('.variant-lane')).toHaveCount(2);await expect(pressedVariant(page)).toHaveCount(1); await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true})).not.toContainText(f.selected_negative); @@ -875,7 +882,7 @@ test('turning Show archived on again after a hidden archive shows the newly arch await toggle().click();await expect(toggle()).toHaveAttribute('aria-pressed','false');await expect(variantsPanel(page).locator('.library-variant-archived')).toHaveCount(0); await selectPair(page,'Select negative variant 2'); await page.getByRole('button',{name:'Edit metadata',exact:true}).click();const dialog=editorDialog(page);await ready(dialog); - await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();await button(dialog,'Archive').click();await expect(dialog).toHaveCount(0); + await button(dialog,'Archive variant…').click();await expect(button(dialog,'Archive')).toBeEnabled();await button(dialog,'Archive').click();await expect(dialog).toHaveCount(0); await expect(toggle()).toHaveText('Show archived (2)');await toggle().click(); await expect(variantsPanel(page).locator('.library-variant-archived')).toHaveCount(2); await expect(laneLabel(page,'· 2 runs · Archived')).toHaveCount(1);await uniqueDotNames(page); diff --git a/web/js/workspace/library-delete.mjs b/web/js/workspace/library-delete.mjs index 52062a79..e026e220 100644 --- a/web/js/workspace/library-delete.mjs +++ b/web/js/workspace/library-delete.mjs @@ -328,3 +328,31 @@ export function previewOutcome(failure) { if (failure?.code === 'busy') return failureOutcome(failure, {}); return { toast: COPY.reviewFailed, committed: false, reload: false }; } + +/** + * #481: review and run a delete of `ids` outside the selection bar (a prompt's own page): the same preview, review + * (#482's choice included), backup and write. `onSettled(outcome, result, failure)` runs once the write has answered + * (or the preview failed: `result` null); a dismissed review calls `onDismiss()`. The toast is the caller's. + */ +export async function reviewPromptDelete({ api, scope, trigger, ids: promptIds, epoch, titleOf, onSettled, onDismiss = () => {} }) { + const body = { prompt_ids: promptIds, expected_epoch: epoch }; + let preview = null, failure = null; + try { + preview = await api.post(PREVIEW_URL, body, { signal: scope.signal }); + } catch (error) { + failure = error; + } + if (scope.signal.aborted) return; + if (failure !== null || !preview?.success) { onSettled(previewOutcome(failure), null, failure); return; } + const review = { ...reviewOf(preview), epoch }; + openDeleteReview({ scope, trigger, review, titleOf, onDismiss, + onStart: async chosen => { + let result = null, error = null; + try { + result = await api.post(DELETE_URL, deleteBody(chosen)); + } catch (caught) { + error = caught; + } + onSettled(deleteOutcome(result, error, chosen), result?.success ? result : null, error); + } }); +} diff --git a/web/js/workspace/library-editor.mjs b/web/js/workspace/library-editor.mjs index d7eda159..f8ba02dd 100644 --- a/web/js/workspace/library-editor.mjs +++ b/web/js/workspace/library-editor.mjs @@ -122,7 +122,7 @@ function datalist(id, values) { * exit when prompt storage was restored; the dialog closes first. */ export function createLibraryEditor({api, parentScope, onCommitted = () => {}, onReconciled = () => {}, - onRefreshRequired = () => window.location.reload()}) { + onRefreshRequired = () => window.location.reload(), onDeletePrompt = null}) { let session = null, disposed = false; const dialog = el('dialog', undefined, 'library-editor'); dialog.setAttribute('aria-labelledby', 'library-editor-title'); @@ -222,7 +222,10 @@ export function createLibraryEditor({api, parentScope, onCommitted = () => {}, o } else form.append(el('p', NEGATIVE_HELP, 'small')); const line = statusLine(), recovery = new Map(); const actions = el('div', undefined, 'dialog-actions'); - const revise = createButton('Create text revision'), archive = createButton('Archive…', 'danger'); + // D161 (#481): the variant's archive is named for it, beside the prompt's own Delete prompt… (the concept's one + // "Archive…" in this row archives the prompt, app.js 976). + const revise = createButton('Create text revision'), archive = createButton('Archive variant…', 'danger'); + const removePrompt = createButton('Delete prompt…', 'danger'); const cancel = createButton('Cancel'), save = createButton('Save', 'ca-primary'); save.type = 'submit'; form.append(line.note, actions); @@ -237,6 +240,12 @@ export function createLibraryEditor({api, parentScope, onCommitted = () => {}, o cancel.addEventListener('click', () => finish(), {signal: views.signal}); revise.addEventListener('click', () => open({mode: 'revision', positiveId, pairId, trigger: session.trigger}), {signal: views.signal}); archive.addEventListener('click', () => archiveView(), {signal: views.signal}); + // #481: the prompt's own delete review (#443/#482) replaces this dialog; focus returns to its trigger. + removePrompt.addEventListener('click', () => { + const {trigger} = session; + finish(); + onDeletePrompt?.({positiveId, trigger}); + }, {signal: views.signal}); if (!negativeOnly) loadVocabulary(tags, views.signal); let categoryKey = null, refocus = null; @@ -256,6 +265,7 @@ export function createLibraryEditor({api, parentScope, onCommitted = () => {}, o cancel.disabled = close.disabled = saving; // After a restore the numeric IDs may name another prompt; only the Library reload exit stays. revise.disabled = saving || restoredStorage(state); + removePrompt.disabled = saving || restoredStorage(state); const pairProblem = readOnly && !state.pair && pairId === null ? NO_PAIR : state.pair?.archived ? ALREADY_ARCHIVED : null; archive.disabled = !SUBMITTABLE.has(state.phase) || !state.pair || state.pair.archived; const status = statusOf(state, false, mode); @@ -263,7 +273,8 @@ export function createLibraryEditor({api, parentScope, onCommitted = () => {}, o if (!status && pairProblem) archive.setAttribute('aria-describedby', line.note.id); else archive.removeAttribute('aria-describedby'); const retry = recoveryButton(recovery, status?.action, views.signal); - setChildren(actions, [...(retry ? [retry] : []), ...(readOnly ? [revise, archive] : []), cancel, save]); + const prompt = readOnly && onDeletePrompt ? [removePrompt] : []; + setChildren(actions, [...(retry ? [retry] : []), ...(readOnly ? [revise, archive, ...prompt] : []), cancel, save]); refocus = restoreFocus(refocus, state, [retry, save]); if (state.phase === 'complete') finish(); } diff --git a/web/js/workspace/library.mjs b/web/js/workspace/library.mjs index 761e810b..4dae613e 100644 --- a/web/js/workspace/library.mjs +++ b/web/js/workspace/library.mjs @@ -17,7 +17,7 @@ import {libraryFilters,libraryViewState} from './saved-views.mjs'; import {openRefineDialog,openContentDialog} from './workspace-dialogs.mjs'; import {mountLibrarySelection} from './library-selection.mjs'; import {fallBackToOriginal,replaceOnError,showsThumbnail,thumbnailSource} from './thumbnail-support.mjs'; -import {COPY as DELETE_COPY,RESTORE_URL,archivedNoticeText,restoreOutcome} from './library-delete.mjs'; +import {COPY as DELETE_COPY,RESTORE_URL,archivedNoticeText,restoreOutcome,reviewPromptDelete} from './library-delete.mjs'; let shelfVisit=null; // Every confirmed receipt advances this; a shelf mounted before it never restores its cached stream. @@ -496,7 +496,28 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on let pendingSelection=takeSavedSelection(positiveId); if(selectedPairs.get(positiveId)?.id!==pendingSelection) selectedPairs.delete(positiveId); const loader=createLibraryDetail({api,onChange:render});scope.cleanup(()=>loader.dispose()); - const editor=createLibraryEditor({api,parentScope:scope,onCommitted:invalidateLibraryAfterWrite,onReconciled:reconciled,onRefreshRequired:reloadView}); + const editor=createLibraryEditor({api,parentScope:scope,onCommitted:invalidateLibraryAfterWrite,onReconciled:reconciled,onRefreshRequired:reloadView, + 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; + 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; + 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});}, + onSettled:(outcome,result,failure)=>{ + deleting=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; + showWorkspaceToast(outcome.toast); + // Restored storage: the whole Library view reads again, as the editor's own restore path. + if(failure?.code==='storage-changed') {reloadView();return;} + if(result && Number.isSafeInteger(result.deleted) && result.deleted>0) {focusHeadingOnMount=true;window.location.hash='#/library';return;} + if(outcome.reload) {detail=null;loader.load(positiveId);focusHeading(main);} + }}); + } scope.listen(back,'click',()=>{window.location.hash='#/library';}); scope.listen(edit,'click',()=>{ const draft=metadataDrafts.get(positiveId);metadataDrafts.delete(positiveId);