Compare commits
50
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a782c9830d | ||
|
|
aa125d82e3 | ||
|
|
704db4be8a | ||
|
|
50989a7bd6 | ||
|
|
fbaf02f827 | ||
|
|
0faf2bea50 | ||
|
|
b968730cc4 | ||
|
|
f87f2c43d0 | ||
|
|
d0ffcccc23 | ||
|
|
6fdcbacfc1 | ||
|
|
bbc8c418de | ||
|
|
fd20968bc8 | ||
|
|
96c2a293a4 | ||
|
|
da136415da | ||
|
|
e47f05e6be | ||
|
|
3e2339fe96 | ||
|
|
52531a8be4 | ||
|
|
7ff785cb5a | ||
|
|
775e85b73a | ||
|
|
c1b5fd3fc0 | ||
|
|
e9d01c917b | ||
|
|
0d59de637e | ||
|
|
917aa838cb | ||
|
|
8e9750226d | ||
|
|
78993dcb4d | ||
|
|
c36ea7a0fb | ||
|
|
1153de26fc | ||
|
|
e14ec03945 | ||
|
|
990ebee79b | ||
|
|
d44cd198c4 | ||
|
|
7ae30212b9 | ||
|
|
94355a6311 | ||
|
|
4a7875c71d | ||
|
|
d9988f246a | ||
|
|
4186c9a053 | ||
|
|
07c16dca58 | ||
|
|
cd82cdcd1b | ||
|
|
a6927489a8 | ||
|
|
323f2617d8 | ||
|
|
53cfd8b035 | ||
|
|
f30db7713d | ||
|
|
567b06ac48 | ||
|
|
a543ecff6f | ||
|
|
4888427a1e | ||
|
|
9f55f0cc79 | ||
|
|
4022298446 | ||
|
|
6372bc7c63 | ||
|
|
d4d99b7e18 | ||
|
|
ebd43a8140 | ||
|
|
72df65113a |
+17
@@ -2861,6 +2861,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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;});
|
||||
|
||||
@@ -175,6 +175,124 @@ 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 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.
|
||||
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();
|
||||
// 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();
|
||||
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 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);
|
||||
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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -642,14 +649,14 @@ test('restored storage after a confirmed save or before an archive offers an exp
|
||||
const saved=page.waitForResponse(item=>item.request().method()==='PATCH'&&new URL(item.url()).pathname===`/prompt_manager/library/${f.positive_id}`);
|
||||
await button(dialog,'Save').click();await saved;
|
||||
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);
|
||||
@@ -666,10 +673,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();
|
||||
@@ -736,7 +743,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);
|
||||
@@ -879,7 +886,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);
|
||||
|
||||
@@ -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);
|
||||
} });
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,9 +496,37 @@ 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;
|
||||
// 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).
|
||||
holdEdit(true);
|
||||
reviewPromptDelete({api,scope,trigger:edit,ids:[shown.positive.id],epoch:shown.library_epoch,
|
||||
titleOf:()=>displayTitle(shown.positive),onDismiss:()=>{holdEdit(false);if(edit.isConnected) edit.focus({preventScroll:true});},
|
||||
onSettled:(outcome,result,failure)=>{
|
||||
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;
|
||||
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',()=>{
|
||||
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});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user