From 6fdcbacfc1d372ab02641da4a704cfec3d2929a2 Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Thu, 29 Oct 2026 13:11:23 -0700 Subject: [PATCH] =?UTF-8?q?feat(library):=20Delete=20prompt=E2=80=A6=20on?= =?UTF-8?q?=20a=20prompt's=20page=20(#481)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The recipe editor's actions read Create text revision, Archive variant… (the selected negative variant's archive, renamed from Archive… so the two danger buttons cannot be confused) and Delete prompt…, which closes the editor and runs the Library's Delete… review for that prompt: the same preview, saved-pair choice (Archive stays the default), backup and write. A delete opens the Prompt library; an archive reads the page again. Recorded as D161 in TESTING.md and in the editor concept comparison. --- TESTING.md | 17 ++++ .../library-editor-canonical-comparison.mjs | 10 ++- tests/browser/library-editor-visual.spec.mjs | 2 +- .../browser/workspace-library-delete.spec.mjs | 83 +++++++++++++++++++ .../browser/workspace-library-editor.spec.mjs | 55 ++++++------ web/js/workspace/library-delete.mjs | 28 +++++++ web/js/workspace/library-editor.mjs | 17 +++- web/js/workspace/library.mjs | 25 +++++- 8 files changed, 205 insertions(+), 32 deletions(-) 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);