diff --git a/tests/browser/full-inspector.spec.mjs b/tests/browser/full-inspector.spec.mjs index e4efc5a7..7bbd7979 100644 --- a/tests/browser/full-inspector.spec.mjs +++ b/tests/browser/full-inspector.spec.mjs @@ -144,9 +144,10 @@ test('panel matches the concept order and copy',async({page,request})=>{ await expect(panel.locator('.meta-grid dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']); await expect(panel.locator('.meta-grid dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']); const footer=panel.locator('.detail-footer'); - await expect(footer.locator('button')).toHaveText(['Send to ComfyUI','Prompt history']); + // #331 D139: every listed file's footer ends with Collections… (folder icon). + await expect(footer.locator('button')).toHaveText(['Send to ComfyUI','Prompt history','Collections…']); await expect(footer.locator('button.send')).toHaveAccessibleName(`Send prompt pair to ComfyUI: ${POSITIVE_TITLE}`); - await expect(footer.locator('button.send svg.icon')).toHaveCount(1);await expect(footer.locator('button:not(.send) svg.icon')).toHaveCount(1); + await expect(footer.locator('button.send svg.icon')).toHaveCount(1);await expect(footer.locator('button:not(.send) svg.icon')).toHaveCount(2); await expect(panel).not.toContainText(/prompt evidence|Recipe tags|Positive prompt tags/); // Execution recorded and embedded parameters (D21), with the extension chip naming an unverified format. reads.patch=data=>{for (const role of ['positive','negative']) data.prompts[role].source='execution';}; @@ -156,10 +157,11 @@ test('panel matches the concept order and copy',async({page,request})=>{ await selectFile(page,'ambiguous.png');await expect(panel.locator('.prompt-section.positive .prompt-text')).toHaveText('embedded positive'); await expect(panel.locator('span.chip.blue')).toHaveText('Embedded parameters'); await expect(panel.locator('.panel-heading .chip')).toHaveText('File extension PNG');await expect(panel.locator('.panel-heading .chip .ca-visually-hidden')).toHaveText('File extension '); - await expect(panel.locator('.detail-footer button')).toHaveText(['Send to ComfyUI']); - // Unlinked: no badge, category, tags, pair or history. + await expect(panel.locator('.detail-footer button')).toHaveText(['Send to ComfyUI','Collections…']); + // Unlinked: no badge, category, tags, pair or history; the footer holds only Collections… (D139). reads.patch=null;await selectFile(page,'empty.png');await expect(panel.locator('.panel-heading h2')).toHaveText('empty.png'); - expect(await panelStructure(panel)).toEqual({outer:['img.detail-cover','div.detail-inner'], + await expect(panel.locator('.detail-footer button')).toHaveText(['Collections…']); + expect(await panelStructure(panel)).toEqual({outer:['img.detail-cover','div.detail-inner','div.detail-footer'], inner:['div.panel-heading','p.small','section.prompt-section.positive','section.prompt-section.negative','dl.meta-grid']}); await expect(panel.locator('.meta-grid dd')).toHaveText(Array(6).fill('Not recorded')); // Ambiguous saved association: the same honest absence. @@ -210,7 +212,7 @@ test('pair send and history appear only with real roles and link',async({page,re await expect(panel.getByRole('button',{name:'Send prompt pair to ComfyUI: explicit-empty.png',exact:true})).toBeVisible(); await expect(panel.getByRole('button',{name:'Prompt history',exact:true})).toHaveCount(0); await selectFile(page,'empty.png');await expect(panel.locator('.panel-heading h2')).toHaveText('empty.png'); - await expect(panel.locator('.detail-footer')).toHaveCount(0);await expect(panel.getByRole('button',{name:/^Send prompt pair/})).toHaveCount(0); + await expect(panel.locator('.detail-footer button')).toHaveText(['Collections…']);await expect(panel.getByRole('button',{name:/^Send prompt pair/})).toHaveCount(0); }); test('no provenance, raw or download controls in the side panel',async({page,request})=>{ @@ -238,8 +240,8 @@ test('viewer details hold provenance, raw records and downloads',async({page,req await page.setViewportSize({width:1600,height:1100});const reads=await patchInspection(page); await fullFixture(page,request);let details=await viewerDetails(page,'complete.png'); // The panel content without the cover, D117's Copy settings summary after the facts, then the two disclosures, - // then the pair send and history buttons (D20). - expect(await viewerStructure(details)).toEqual({outer:['div','button.send','button'], + // then the pair send and history buttons (D20) and D139's Collections…. + expect(await viewerStructure(details)).toEqual({outer:['div','button.send','button','button'], inner:['div.panel-heading','p.small','div.tags','section.prompt-section.positive','section.prompt-section.negative','div.tags','dl.meta-grid','button.settings-summary','details','details']}); await expect(details.locator(':scope > div > button')).toHaveText('Copy settings summary'); await expect(details.locator(':scope > div > button.settings-summary')).toHaveCSS('white-space','normal'); @@ -250,7 +252,7 @@ test('viewer details hold provenance, raw records and downloads',async({page,req await expect(details.locator(':scope > div > .tags').last().locator('span.chip')).toHaveText(['fixture','geometric']); await expect(details.locator('.meta-grid').first().locator('dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']); await expect(details.locator('summary')).toHaveText(['File & provenance','Workflow & raw metadata']); - await expect(details.locator(':scope > button')).toHaveText(['Send to ComfyUI','Prompt history']); + await expect(details.locator(':scope > button')).toHaveText(['Send to ComfyUI','Prompt history','Collections…']); await expect(details.locator(':scope > button.send')).toHaveAccessibleName(`Send prompt pair to ComfyUI: ${POSITIVE_TITLE}`); // File & provenance: the honest facts and the concept's "Prompt evidence" line; no per-fact source lines. const provenance=details.locator('details').first();await provenance.locator('summary').click(); @@ -275,9 +277,10 @@ test('viewer details hold provenance, raw records and downloads',async({page,req await expect(details.getByText('Prompt evidence: embedded parameters',{exact:true})).toBeVisible(); await expect(details.locator('.panel-heading .chip')).toHaveText('File extension PNG'); await expect(details.locator('details dl.meta-grid dd').nth(4)).toHaveText('PNG extension');await expect(details).not.toContainText('.PNG'); - // No badge, no line; no resolved pair or link, no buttons after the details. + // No badge, no line; no resolved pair or link: after the details only D139's Collections…. reads.patch=null;await page.keyboard.press('Escape');details=await viewerDetails(page,'empty.png'); - expect((await viewerStructure(details)).outer).toEqual(['div']); + expect((await viewerStructure(details)).outer).toEqual(['div','button']); + await expect(details.locator(':scope > button')).toHaveText(['Collections…']); await details.getByText('File & provenance',{exact:true}).click();await expect(details).not.toContainText('Prompt evidence'); // The history button leaves the viewer for the prompt's history. await page.keyboard.press('Escape');details=await viewerDetails(page,'complete.png'); diff --git a/tests/browser/inspector-concept.spec.mjs b/tests/browser/inspector-concept.spec.mjs index 0a43d7f1..45c1b16a 100644 --- a/tests/browser/inspector-concept.spec.mjs +++ b/tests/browser/inspector-concept.spec.mjs @@ -45,10 +45,11 @@ function withCategoryScope(inventory) { } /** - * Recorded inspector deviations only (spec §8, TESTING.md D3, D20–D22, D34, D38 and D117); everything else must + * Recorded inspector deviations only (spec §8, TESTING.md D3, D20–D22, D34, D38, D117 and D139); everything else must * equal production. `reference.viewerPlain` is the concept's own full details * with its tag chips made plain (D20), D117's summary button added and, under a coarse pointer, D34's targets, all at - * runtime: the browser lays out what follows them, as the Images spec's removed toolbar items do. + * runtime: the browser lays out what follows them, as the Images spec's removed toolbar items do. D139's + * "Collections…" is drawn after Prompt history in both the concept's panel footer and its full details. */ export function mapInspectorConcept(reference,{viewport}) { const {panel,viewerPlain,fileControls}=reference; @@ -105,6 +106,22 @@ async function openProductionPanel(page,theme) { await settle(page); } +/** + * #331 D139: production's "Collections…" after Prompt history in `selector`'s buttons, drawn in the concept as its own + * Prompt history button (no action) with the concept's folder icon (its Images filter chip's); returns an undo. + */ +async function withCollectionsButton(page,selector) { + await page.evaluate(selector=>{ + const history=[...document.querySelectorAll(`${selector} > button`)].find(button=>button.textContent.includes('Prompt history')); + const button=history.cloneNode(false); + for(const key of Object.keys(button.dataset))delete button.dataset[key]; + button.append(document.querySelector('.filter-row .chip > svg.icon').cloneNode(true),' Collections…'); + history.after(button);(window.__collectionsButtons??=[]).push(button); + },selector); + await settle(page); + return async()=>{await page.evaluate(()=>{for(const button of window.__collectionsButtons??[])button.remove();delete window.__collectionsButtons;});}; +} + /** * The concept's tag chips in the open viewer as plain `span.chip`s (D20), D34's coarse targets and D117's "Copy * settings summary" after the six facts (LI-4), drawn as the concept's own plain button (its "Download sample JSON", @@ -122,8 +139,9 @@ async function plainViewerChips(page) { summary.textContent='Copy settings summary';summary.classList.add('settings-summary'); document.querySelector('#viewer-details > div > dl.meta-grid').after(summary);window.__summaryButton=summary; }); - await settle(page); + const restoreCollections=await withCollectionsButton(page,'#viewer-details'); return async()=>{ + await restoreCollections(); await page.evaluate(()=>{for(const [span,button] of window.__plainChips)span.replaceWith(button);delete window.__plainChips; window.__summaryButton.remove();delete window.__summaryButton;}); await restoreTargets(); @@ -162,7 +180,9 @@ async function viewerOf(page,concept,mutation=null) { return {viewerDetails,viewerPlain,fileControls}; } async function inventory(page,concept=false) { + const restoreCollections=concept?await withCollectionsButton(page,'#image-inspector > .detail-footer'):null; const {panel}=await inspectorOf(page); + await restoreCollections?.(); const {viewerDetails,viewerPlain,fileControls}=await viewerOf(page,concept); return concept?{panel,viewerDetails,viewerPlain,fileControls}:{panel,viewerDetails,fileControls}; } @@ -325,7 +345,7 @@ test('a recipe-linked image without recipe metadata shows its positive prompt me await expect(rows.first().locator('.chip .ca-visually-hidden')).toHaveText('Positive prompt category: '); await expect(rows.nth(1).locator('button.chip')).toHaveText(['minimal','warm_light','editorial']); await expect(rows.nth(1).locator('span.chip')).toHaveCount(0); - await expect(panelOf(page).locator('.detail-footer button')).toHaveText(['Send to ComfyUI','Prompt history']); + await expect(panelOf(page).locator('.detail-footer button')).toHaveText(['Send to ComfyUI','Prompt history','Collections…']); }); // Task C3 (D88, #249 PR C): the concept's chip row is the image's own tags (`i.tags`, app.js a402). With those names @@ -380,6 +400,13 @@ test('inspector comparison detects mutations',async({page})=>{ expect(()=>assertInspectorMatch(reference,withoutSummary,viewport)).toThrow(/viewerDetails\.structure\.inner/); assertInspectorMatch(reference,await viewerMutated(),viewport); + // D139: Collections… is part of the mapped concept, so losing it is a difference. + const restoreButton=await mutate(page,{remove:['#image-inspector > .detail-footer > button:last-child']}); + const withoutCollections=await measure(); + expect(()=>assertInspectorMatch(reference,withoutCollections,viewport)).toThrow(/panel\.footer/); + await restoreButton();await settle(page); + assertInspectorMatch(reference,await measure(),viewport); + await mutate(page,{remove:['#image-inspector > .detail-footer']}); const domMutated=await measure(); expect(()=>assertInspectorMatch(reference,domMutated,viewport)).toThrow(/panel\.footer/); diff --git a/tests/browser/workspace-collections.spec.mjs b/tests/browser/workspace-collections.spec.mjs index e731d599..233182d8 100644 --- a/tests/browser/workspace-collections.spec.mjs +++ b/tests/browser/workspace-collections.spec.mjs @@ -520,3 +520,134 @@ for (const theme of ['dark', 'light']) { await expect(row(page, 'Phone')).toBeVisible(); }); } + +// #331 D139 (design §8): the shared inspector's Collections… in the Images side panel and in the viewer. +const panel = page => page.locator('#image-inspector'); +const collectionsDialog = page => page.getByRole('dialog', { name: 'Collections', exact: true }); +const choice = (page, name) => collectionsDialog(page).getByRole('checkbox', { name, exact: true }); +const newCollection = page => collectionsDialog(page).getByRole('textbox', { name: 'New collection', exact: true }); + +async function membership(request, path) { + const [image] = (await (await request.get(`/prompt_manager/images/output?mode=workspace&q=${encodeURIComponent(path)}`)).json()).images; + const query = new URLSearchParams({ root_index: String(image.root_index), root_scope: image.root_scope, path: image.path }); + const response = await request.get(`${API}/membership?${query}`); + await expect(response).toBeOK(); + return (await response.json()).collection_ids; +} + +test('Collections… in the side panel adds, removes and creates for one image (CUJ 6)', async ({ page, request }) => { + const manifest = await seed(request); + const { Landscapes, Experiments } = manifest.collections; + await page.goto(`${workspace}#/images`); + // The newest image, landscape_01, is the panel's. + await expect(panel(page).getByRole('heading', { level: 2 })).toHaveText('landscape_01.png'); + const open = panel(page).getByRole('button', { name: 'Collections…', exact: true }); + await expect(panel(page).locator('.detail-footer > button').last()).toHaveText('Collections…'); + await open.click(); + await expect(collectionsDialog(page)).toBeVisible(); + await expect(choice(page, 'Landscapes')).toBeChecked(); + await expect(choice(page, 'Experiments')).not.toBeChecked(); + await expect(collectionsDialog(page).locator('label.tag-suggestion')).toHaveText(['Experiments', 'Landscapes']); + await expect(collectionsDialog(page).locator('.dialog-actions button')).toHaveText(['Cancel', 'Save']); + await choice(page, 'Landscapes').uncheck(); + await choice(page, 'Experiments').check(); + await newCollection(page).fill('Grids'); + await collectionsDialog(page).getByRole('button', { name: 'Save', exact: true }).click(); + await expect(collectionsDialog(page)).toHaveCount(0); + await expect(toast(page)).toHaveText('Collections updated.'); + await expect(open).toBeFocused(); + await expect(row(page, 'Grids').locator('.nav-count')).toHaveText('1'); + await expect(row(page, 'Landscapes').locator('.nav-count')).toHaveText('11'); + await expect(row(page, 'Experiments').locator('.nav-count')).toHaveText('9'); + const grids = (await listed(request)).find(item => item.name === 'Grids'); + expect((await membership(request, 'landscape_01')).sort()).toEqual([Experiments.id, grids.id].sort()); + expect(Landscapes.id).not.toBe(grids.id); + // The Images page lists the same files; the dialog opened again shows the new state. + await expect(imageCards(page)).toHaveCount(21); + await open.click(); + await expect(choice(page, 'Grids')).toBeChecked(); + await expect(choice(page, 'Experiments')).toBeChecked(); + await expect(choice(page, 'Landscapes')).not.toBeChecked(); + // Save without a change sends nothing. + let writes = 0; + page.on('request', request => { if (request.url().includes(`${API}/items`)) writes++; }); + await collectionsDialog(page).getByRole('button', { name: 'Save', exact: true }).click(); + await expect(collectionsDialog(page)).toHaveCount(0); + expect(writes).toBe(0); +}); + +test('Collections… in the viewer keeps the viewer until it closes (CUJ 7)', async ({ page, request }) => { + const manifest = await seed(request); + await page.goto(`${workspace}#/collection/${manifest.collections.Landscapes.id}`); + await expect(imageCards(page)).toHaveCount(12); + await page.getByRole('button', { name: 'Open landscape_01.png', exact: true }).click(); + const viewer = page.locator('#viewer-dialog[open]'); + await expect(viewer.locator('#viewer-title')).toContainText('landscape_01.png'); + await viewer.locator('#viewer-details').getByRole('button', { name: 'Collections…', exact: true }).click(); + await expect(choice(page, 'Landscapes')).toBeChecked(); + await choice(page, 'Landscapes').uncheck(); + await collectionsDialog(page).getByRole('button', { name: 'Save', exact: true }).click(); + await expect(toast(page)).toHaveText('Collections updated.'); + await expect(collectionsDialog(page)).toHaveCount(0); + // The viewer stays on the image; the page keeps listing it while the viewer is open. + await expect(viewer.locator('#viewer-title')).toContainText('landscape_01.png'); + await expect(row(page, 'Landscapes').locator('.nav-count')).toHaveText('11'); + await expect(imageCards(page)).toHaveCount(12); + await viewer.getByRole('button', { name: 'Close image viewer', exact: true }).click(); + await expect(viewer).toHaveCount(0); + await expect(imageCards(page)).toHaveCount(11); + await expect(card(page, 'collections/landscape_01.png')).toHaveCount(0); +}); + +test('a failed membership read says so and Retry reads it again', async ({ page, request }) => { + await seed(request); + let failing = true; + await page.route(`**${API}/membership?*`, route => failing + ? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"Collections could not be read."}' }) + : route.fallback()); + await page.goto(`${workspace}#/images`); + await panel(page).getByRole('button', { name: 'Collections…', exact: true }).click(); + await expect(collectionsDialog(page).getByRole('alert').first()).toHaveText('Collections could not be loaded.'); + await expect(collectionsDialog(page).getByRole('checkbox')).toHaveCount(0); + await expect(collectionsDialog(page).getByRole('button', { name: 'Save', exact: true })).toHaveAttribute('aria-disabled', 'true'); + failing = false; + await collectionsDialog(page).getByRole('button', { name: 'Retry', exact: true }).click(); + await expect(choice(page, 'Landscapes')).toBeChecked(); + await expect(collectionsDialog(page).getByRole('button', { name: 'Retry', exact: true })).toBeHidden(); + // A failed write keeps the dialog with the current membership. + await page.route(`**${API}/items`, route => route.fulfill({ status: 500, contentType: 'application/json', + body: '{"success":false,"error":"The collection could not be changed."}' })); + await choice(page, 'Experiments').check(); + await collectionsDialog(page).getByRole('button', { name: 'Save', exact: true }).click(); + await expect(collectionsDialog(page).getByRole('alert')).toHaveText('The collections could not be updated. Try again.'); + await expect(choice(page, 'Experiments')).not.toBeChecked(); + await expect(choice(page, 'Landscapes')).toBeChecked(); +}); + +test('a file in no collection with no collections at all can start one', async ({ page }) => { + await page.goto(`${workspace}#/images`); + await panel(page).getByRole('button', { name: 'Collections…', exact: true }).click(); + await expect(collectionsDialog(page)).toContainText('No collections yet. Name one below.'); + await newCollection(page).fill('First'); + await collectionsDialog(page).getByRole('button', { name: 'Save', exact: true }).click(); + await expect(toast(page)).toHaveText('Collections updated.'); + await expect(rows(page)).toHaveCount(1); + await expect(row(page, 'First').locator('.nav-count')).toHaveText('1'); +}); + +for (const theme of ['dark', 'light']) { + test(`the Collections dialog fits 390x844 ${theme}`, async ({ page, request }) => { + await seed(request); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto(`${workspace}#/images`); + await page.getByRole('combobox', { name: 'Theme', exact: true }).selectOption(theme); + await panel(page).getByRole('button', { name: 'Collections…', exact: true }).click(); + await expect(choice(page, 'Landscapes')).toBeChecked(); + const fits = await collectionsDialog(page).evaluate(dialog => { + const box = dialog.getBoundingClientRect(); + return box.left >= 0 && box.right <= window.innerWidth && dialog.scrollWidth <= dialog.clientWidth; + }); + expect(fits).toBe(true); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); + }); +} diff --git a/tests/js/workspace-file-inspector.test.js b/tests/js/workspace-file-inspector.test.js index 6371f59c..f0571719 100644 --- a/tests/js/workspace-file-inspector.test.js +++ b/tests/js/workspace-file-inspector.test.js @@ -363,3 +363,196 @@ test('the D117 wrap rule targets only the settings summary button', async () => assert.deepEqual(rules.map(line => line.trim()), ['.ca-tools #viewer-details .settings-summary { white-space: normal; /* D117 */ }']); assert.doesNotMatch(css, /#viewer-details > div > button/); }); + +// #331 D139 (design §8): the shared inspector's "Collections…" footer button and its checklist dialog. +const collectionsDialog = await import('../../web/js/workspace/inspector-collections.mjs').catch(() => null); +const dialogModule = () => { assert.ok(collectionsDialog, 'inspector-collections.mjs is not implemented'); return collectionsDialog; }; +const COLLECTION_SCOPE = 'a'.repeat(64); +const FILE = {root_index:2, root_scope:COLLECTION_SCOPE, path:'a.png'}; +function collectionsStore({member = [3], membership, add, remove} = {}) { + const calls = []; + const store = { + calls, + collections:[{id:3, name:'LoRa testing', count:3, missing:0}, {id:5, name:'Portraits', count:1, missing:0}], + membership: async target => { calls.push(['membership', target]); return membership ? membership() : [...member]; }, + add: async (where, items) => { calls.push(['add', where, items]); if (add) return add(where, items); + return {collection:{id:where.collectionId ?? 9, name:where.name ?? 'x'}, created:!!where.name, added:1, already:0, notFound:0}; }, + remove: async (id, items) => { calls.push(['remove', id, items]); if (remove) return remove(id, items); + return {collection:{id, name:'x'}, removed:1}; }, + settled: async () => ({}), + }; + return store; +} +function footerButtons(f) { + return f.walk(f.container, n => n.tagName === 'button').map(n => n.textContent.trim()); +} +test('inspector shows Collections… only for a rooted file with a store', async () => { + for (const variant of ['panel', 'full']) { + const f = mountedDom(), scope = createScope(); + try { + const view = mountFileInspector(f.container, {sender:{open() {}}, parentScope:scope, variant, collections:collectionsStore()}); + view.setState({loading:false, error:false, record:{...record(), historyTarget:'#/library/1'}}); + const labels = footerButtons(f); + assert.ok(labels.includes('Collections…'), `${variant}: the button is offered`); + // After Prompt history, as the last footer action. + assert.equal(labels.at(-1), 'Collections…'); + assert.equal(labels.at(-2), 'Prompt history'); + const button = f.walk(f.container, n => n.tagName === 'button' && n.textContent.trim() === 'Collections…')[0]; + assert.equal(button.children[0].tagName, 'svg', 'with the folder icon'); + view.dispose(); + } finally { scope.dispose(); f.restore(); } + } + // No store (Prompt library galleries, Inspect a file) or a file that is not a listed one: no button. + const local = await localRecord(localPng('Fox Render.png')); + for (const [shown, store] of [[record(), null], [local, collectionsStore()], + [{...record(), identity:{...record().identity, root_scope:null}}, collectionsStore()]]) { + const f = mountedDom(), scope = createScope(); + try { + const view = mountFileInspector(f.container, {sender:{open() {}}, parentScope:scope, variant:'full', collections:store}); + view.setState({loading:false, error:false, record:shown}); + assert.ok(!footerButtons(f).includes('Collections…')); + view.dispose(); + } finally { scope.dispose(); f.restore(); } + } +}); + +/** Open the Collections dialog over FILE; returns its nodes by role and helpers. */ +function openCollections(store, extra = {}) { + const {openCollectionsDialog} = dialogModule(); + const f = mountedDom(), scope = createScope(), toasts = [], changes = []; + let dialog = null, closed = 0; + openCollectionsDialog({scope, collections:store, target:FILE, trigger:null, notify:text => toasts.push(text), + onChanged:change => changes.push(change), openDialog:args => { dialog = args; f.container.append(...args.nodes); }, + closeDialog:() => { closed++; }, ...extra}); + const settle = () => new Promise(resolve => setTimeout(resolve, 0)); + const find = predicate => f.walk(f.container, predicate); + const boxes = () => find(n => n.tagName === 'input' && n.type === 'checkbox'); + const button = text => find(n => n.tagName === 'button' && n.textContent === text)[0]; + const text = () => find(n => n.tagName === 'p').map(n => n.textContent).filter(Boolean); + return {f, scope, toasts, changes, get dialog() { return dialog; }, get closed() { return closed; }, settle, find, boxes, button, text, + done() { scope.dispose(); f.restore(); }}; +} + +test('the Collections dialog loads the membership and lists every collection', async () => { + const store = collectionsStore(); + const view = openCollections(store); + try { + assert.equal(view.dialog.title, 'Collections'); + assert.ok(view.text().includes('Loading collections…')); + assert.equal(view.button('Save').getAttribute('aria-disabled'), 'true'); + await view.settle(); + assert.deepEqual(store.calls, [['membership', FILE]]); + const labels = view.find(n => n.tagName === 'label' && n.className === 'tag-suggestion'); + assert.deepEqual(labels.map(label => label.textContent), ['LoRa testing', 'Portraits']); + assert.deepEqual(view.boxes().map(box => box.checked), [true, false]); + const field = view.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0]; + assert.equal(field.maxLength, 100); + assert.equal(view.find(n => n.tagName === 'label' && n.htmlFor === field.id)[0].textContent, 'New collection'); + assert.deepEqual(view.find(n => n.className === 'dialog-actions')[0].children.map(n => n.textContent), ['Cancel', 'Save']); + assert.equal(view.button('Save').getAttribute('aria-disabled'), null); + } finally { view.done(); } +}); + +test('without collections the dialog says to name one', async () => { + const store = collectionsStore({member:[]}); + store.collections = []; + const view = openCollections(store); + try { + await view.settle(); + assert.ok(view.text().includes('No collections yet. Name one below.')); + assert.equal(view.boxes().length, 0); + } finally { view.done(); } +}); + +test('a failed membership read offers Retry', async () => { + let failing = true; + const store = collectionsStore({membership:() => { if (failing) throw new TypeError('offline'); return [5]; }}); + const view = openCollections(store); + try { + await view.settle(); + assert.ok(view.text().includes('Collections could not be loaded.')); + assert.equal(view.button('Retry').hidden, false); + assert.equal(view.boxes().length, 0); + assert.equal(view.button('Save').getAttribute('aria-disabled'), 'true'); + failing = false; + view.button('Retry').fire('click'); + await view.settle(); + assert.deepEqual(view.boxes().map(box => box.checked), [false, true]); + assert.equal(view.button('Retry').hidden, true); + } finally { view.done(); } +}); + +test('Save sends the new name, each added and each removed collection, in that order', async () => { + const store = collectionsStore(); + const view = openCollections(store); + try { + await view.settle(); + const [lora, portraits] = view.boxes(); + lora.checked = false; portraits.checked = true; + view.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0].value = ' Grids '; + view.button('Save').fire('click'); + await view.settle(); await view.settle(); + assert.deepEqual(store.calls.slice(1), [ + ['add', {name:'Grids'}, [FILE]], + ['add', {collectionId:5}, [FILE]], + ['remove', 3, [FILE]], + ]); + assert.equal(view.closed, 1); + assert.deepEqual(view.toasts, ['Collections updated.']); + assert.deepEqual(view.changes, [{added:[9, 5], removed:[3]}]); + } finally { view.done(); } +}); + +test('Save without a change sends nothing and closes', async () => { + const store = collectionsStore(); + const view = openCollections(store); + try { + await view.settle(); + view.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0].value = ' '; + view.button('Save').fire('click'); + await view.settle(); + assert.deepEqual(store.calls.map(call => call[0]), ['membership']); + assert.equal(view.closed, 1); + assert.deepEqual(view.toasts, []); + assert.deepEqual(view.changes, []); + } finally { view.done(); } +}); + +test('a failed Save keeps the dialog, says so and shows the current membership', async () => { + let reads = 0; + const store = collectionsStore({membership:() => (reads++ ? [5] : [3]), + remove:() => { throw Object.assign(new Error('x'), {status:500}); }}); + const view = openCollections(store); + try { + await view.settle(); + const [lora, portraits] = view.boxes(); + lora.checked = false; portraits.checked = true; + view.button('Save').fire('click'); + // While it saves, × and Escape keep the dialog and Save is busy. + assert.equal(view.dialog.canClose(), false); + assert.equal(view.button('Save').getAttribute('aria-disabled'), 'true'); + for (let index = 0; index < 4; index++) await view.settle(); + assert.equal(view.closed, 0); + assert.deepEqual(view.toasts, []); + const alert = view.find(n => n.getAttribute?.('role') === 'alert')[0]; + assert.equal(alert.textContent, 'The collections could not be updated. Try again.'); + // The add went through: the page hears about it; the list shows what the server holds now. + assert.deepEqual(view.changes, [{added:[5], removed:[]}]); + assert.deepEqual(view.boxes().map(box => box.checked), [false, true]); + assert.equal(view.dialog.canClose(), true); + assert.equal(view.button('Save').getAttribute('aria-disabled'), null); + } finally { view.done(); } +}); + +test('a name the store cannot take fails without a request', async () => { + const store = collectionsStore(); + const view = openCollections(store); + try { + await view.settle(); + view.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0].value = 'bad\u0001name'; + view.button('Save').fire('click'); + for (let index = 0; index < 3; index++) await view.settle(); + assert.deepEqual(store.calls.filter(call => call[0] !== 'membership'), []); + assert.equal(view.find(n => n.getAttribute?.('role') === 'alert')[0].textContent, 'The collections could not be updated. Try again.'); + } finally { view.done(); } +}); diff --git a/web/js/workspace/file-inspector.mjs b/web/js/workspace/file-inspector.mjs index 9a868a8f..0611b676 100644 --- a/web/js/workspace/file-inspector.mjs +++ b/web/js/workspace/file-inspector.mjs @@ -8,6 +8,7 @@ import { showWorkspaceToast } from './toast.mjs'; import { openCopyFallback } from './workspace-dialogs.mjs'; import { displayPreferences } from './preferences.mjs'; import { fallBackToOriginal, thumbnailSource } from './thumbnail-support.mjs'; +import { openCollectionsDialog } from './inspector-collections.mjs'; const RAW_KEYS = ['prompt', 'workflow', 'parameters', 'comfyassets_prompt_pairs']; const RAW_LIMIT = 'Raw metadata exceeds the response limit. Download the original file to inspect it externally.'; @@ -204,7 +205,12 @@ function prefixedChip(prefix, text) { /** D124: the inspector's line while "Load metadata on selection" is off. */ export const DEFERRED_TEXT = 'Metadata loads when you ask for it.'; -export function mountFileInspector(container, {api, sender, parentScope, variant, inline = false, promptContext = '', onTagFilter, onRetry, onRefresh, onLoad, onInteraction = () => {}, copyFallback = openCopyFallback}) { +/** + * #331 D139: with the client store `collections`, a listed file's footer ends with "Collections…", which opens its + * collections checklist; `onCollectionsChanged({added, removed})` hears the confirmed changes. + */ +export function mountFileInspector(container, {api, sender, parentScope, variant, inline = false, promptContext = '', onTagFilter, onRetry, onRefresh, onLoad, onInteraction = () => {}, copyFallback = openCopyFallback, + collections = null, onCollectionsChanged = () => {}}) { if (parentScope.signal.aborted) return {setState() {}, dispose() {}}; const scope = childScope(parentScope); let contentScope; @@ -300,6 +306,14 @@ export function mountFileInspector(container, {api, sender, parentScope, variant }); footer.append(history); } + const member = collections ? revealTarget(record.identity) : null; + if (member) { + const button = createButton(''); + button.append(createWorkspaceIcon('folder'), ' Collections…'); + contentScope.listen(button, 'click', () => openCollectionsDialog({scope, collections, target:member, trigger:button, + onChanged:onCollectionsChanged})); + footer.append(button); + } if (!full && footer.childNodes.length) container.append(footer); } /** diff --git a/web/js/workspace/images.mjs b/web/js/workspace/images.mjs index aaa13d9c..aed1cdf0 100644 --- a/web/js/workspace/images.mjs +++ b/web/js/workspace/images.mjs @@ -279,7 +279,14 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage onSourceChanged:refreshInspectionCollection}); if (bar) main.append(bar.element); main.append(body); + // #331 D139: a change the inspector or the viewer confirmed that touches the open collection lists it again; the + // viewer reports only once it closes. + const inspectorCollections = embedded ? null : collections; + const collectionsChangedBy = ({added, removed}) => { + if (filters.collection && [...added, ...removed].includes(Number(filters.collection))) collectionChanged(); + }; const mediaViewer = createMediaViewer({api,container:main,parentScope:scope,inspectionController:inspection,sender, + collections:inspectorCollections,onCollectionsChanged:collectionsChangedBy, onSelect:image => {if (selection !== imageKey(image)) inspect(image);else inspection.select(image);}, onRefresh:refreshInspectionCollection,onClose:scheduleLoad,onRestoreFocus:image => { const index = state?.items.findIndex(entry => imageKey(entry) === imageKey(image)); @@ -295,7 +302,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage selection = imageKey(image); for (const video of details.querySelectorAll('video')) releaseVideo(video); detailInspector = mountFileInspector(details, {sender, parentScope:scope, variant:'panel', - onTagFilter:embedded ? undefined : filterByTag, onRetry:() => inspection.retry(), onLoad:() => inspection.load(), onRefresh:refreshInspectionCollection}); + onTagFilter:embedded ? undefined : filterByTag, onRetry:() => inspection.retry(), onLoad:() => inspection.load(), onRefresh:refreshInspectionCollection, + collections:inspectorCollections, onCollectionsChanged:collectionsChangedBy}); detailInspector.setState(inspection.state); inspection.select(image); for (const [key, card] of rendered) markSelected(card, key === selection); diff --git a/web/js/workspace/inspector-collections.mjs b/web/js/workspace/inspector-collections.mjs new file mode 100644 index 00000000..a7b780fc --- /dev/null +++ b/web/js/workspace/inspector-collections.mjs @@ -0,0 +1,159 @@ +/** + * The shared inspector's "Collections…" dialog (#331 D139, design §8), beyond the concept: one listed file's + * collections as a checklist in the sidebar's order, a New collection field, then Cancel and Save. + * + * - The file's membership is read when the dialog opens ("Loading collections…"); a failed read says "Collections + * could not be loaded." with Retry, and Save waits for a read that worked. + * - Each row is the concept's checkbox row (`label.tag-suggestion`, app.js a1128). With no collection the list says + * "No collections yet. Name one below." + * - Save sends one request per change, in order: the new name, each newly checked collection, each unchecked one. + * Nothing changed sends nothing and closes. All sent: the dialog closes with "Collections updated.". A failure keeps + * it open with "The collections could not be updated. Try again." and the membership read again; the changes that + * went through still reach `onChanged({added, removed})` (collection ids), as a full success does. + */ +import { createButton } from '../shared/toolkit/core.js'; +import { closeWorkspaceDialog, field, openContentDialog } from './workspace-dialogs.mjs'; +import { showWorkspaceToast } from './toast.mjs'; +import { COLLECTIONS_UPDATED_TOAST, MAX_COLLECTION_NAME_LENGTH, MEMBERSHIP_FAILED_TEXT, MEMBERSHIP_LOADING_TEXT, + NO_COLLECTIONS_TEXT, UPDATE_FAILED_TEXT, normalizeCollectionName } from './collections.mjs'; + +export const COLLECTIONS_DIALOG_TITLE = 'Collections'; +export const NEW_COLLECTION_LABEL = 'New collection'; + +function el(tag, text, className) { + const node = document.createElement(tag); + if (text !== undefined) node.textContent = text; + if (className) node.className = className; + return node; +} + +/** + * Open the dialog for `target` (`{root_index, root_scope, path}`) with the client store `collections`. `openDialog` + * and `closeDialog` are the dialog seams; `notify` the toast. + */ +export function openCollectionsDialog({ scope, collections, target, trigger = null, notify = showWorkspaceToast, onChanged = () => {}, + openDialog = openContentDialog, closeDialog = closeWorkspaceDialog }) { + const local = new AbortController(); + const active = () => !scope.signal.aborted && !local.signal.aborted; + let member = null, saving = false; + const status = el('p', '', 'small'); + const retry = createButton('Retry'); + const list = el('div'); + list.setAttribute('role', 'group'); + list.setAttribute('aria-label', COLLECTIONS_DIALOG_TITLE); + const input = el('input'); + input.value = ''; + input.maxLength = MAX_COLLECTION_NAME_LENGTH; + input.autocomplete = 'off'; + const problem = el('p', '', 'small'); + problem.setAttribute('role', 'alert'); + problem.hidden = true; + const actions = el('div', undefined, 'dialog-actions'); + const cancel = createButton('Cancel'); + const save = createButton('Save', 'ca-primary'); + actions.append(cancel, save); + const blocked = () => member === null || saving; + const paintSave = () => { + if (blocked()) save.setAttribute('aria-disabled', 'true'); + else save.removeAttribute('aria-disabled'); + }; + retry.addEventListener('click', () => load()); + cancel.addEventListener('click', () => { if (!saving) closeDialog(); }); + save.addEventListener('click', () => { if (!blocked()) submit(); }); + + /** One checkbox row per collection, checked where the file is a member. */ + function renderList() { + list.replaceChildren(); + if (!collections.collections.length) { + list.append(el('p', NO_COLLECTIONS_TEXT, 'small')); + return; + } + for (const item of collections.collections) { + const row = el('label', undefined, 'tag-suggestion'); + const box = el('input'); + Object.assign(box, { type: 'checkbox', id: `collection-choice-${item.id}`, checked: member.has(item.id) }); + box.dataset.collectionId = String(item.id); + row.htmlFor = box.id; + row.append(box, el('span', item.name)); + list.append(row); + } + } + function showStatus(text, failed) { + status.textContent = text; + status.setAttribute('role', failed ? 'alert' : 'status'); + status.hidden = !text; + retry.hidden = !failed; + } + async function load() { + member = null; + paintSave(); + list.replaceChildren(); + showStatus(MEMBERSHIP_LOADING_TEXT, false); + try { + const ids = await collections.membership(target); + if (!active()) return; + member = new Set(ids); + showStatus('', false); + renderList(); + } catch { + if (!active()) return; + showStatus(MEMBERSHIP_FAILED_TEXT, true); + } + paintSave(); + } + /** The requests Save sends, in order; null when the new name cannot be a collection name. */ + function plan() { + const typed = input.value.trim(); + const name = typed ? normalizeCollectionName(typed) : ''; + if (name === null) return null; + const steps = name ? [{ add: { name } }] : []; + const boxes = [...list.children].map(row => row.children?.[0]).filter(box => box?.dataset?.collectionId); + for (const box of boxes) { + const id = Number(box.dataset.collectionId); + if (box.checked && !member.has(id)) steps.push({ add: { collectionId: id } }); + } + for (const box of boxes) { + const id = Number(box.dataset.collectionId); + if (!box.checked && member.has(id)) steps.push({ remove: id }); + } + return steps; + } + async function submit() { + problem.textContent = ''; + problem.hidden = true; + const steps = plan(); + if (steps?.length === 0) { closeDialog(); return; } + saving = true; + paintSave(); + const added = [], removed = []; + let failed = steps === null; + for (const step of steps ?? []) { + try { + if (step.add) added.push((await collections.add(step.add, [target])).collection.id); + else removed.push((await collections.remove(step.remove, [target])).collection.id); + } catch { + failed = true; + break; + } + if (!active()) return; + if (step.add?.name) input.value = ''; + } + if (!active()) return; + saving = false; + if (added.length || removed.length) onChanged({ added, removed }); + if (!failed) { + closeDialog(); + notify(COLLECTIONS_UPDATED_TOAST); + return; + } + problem.textContent = UPDATE_FAILED_TEXT; + problem.hidden = false; + // A collection deleted meanwhile is re-read by the store; the list then shows what the server holds. + await collections.settled(); + if (active()) await load(); + } + showStatus('', false); + openDialog({ scope, title: COLLECTIONS_DIALOG_TITLE, trigger, canClose: () => !saving, onClose: () => local.abort(), + nodes: [status, retry, list, field(NEW_COLLECTION_LABEL, input, 'collection-new'), problem, actions] }); + load(); +} diff --git a/web/js/workspace/media-viewer.mjs b/web/js/workspace/media-viewer.mjs index 834c9957..05e16685 100644 --- a/web/js/workspace/media-viewer.mjs +++ b/web/js/workspace/media-viewer.mjs @@ -19,7 +19,12 @@ function element(tag, className, text) { return node; } -export function createMediaViewer({api,container,parentScope,inspectionController,sender,onSelect,onRefresh,onRestoreFocus,onClose,adapter = rootedFileAdapter}) { +/** + * #331 D139: with the client store `collections`, the viewer's inspector offers "Collections…"; the changes it confirms + * reach `onCollectionsChanged({added, removed})` once the viewer closes, so the viewer keeps its place while open. + */ +export function createMediaViewer({api,container,parentScope,inspectionController,sender,onSelect,onRefresh,onRestoreFocus,onClose,adapter = rootedFileAdapter, + collections = null,onCollectionsChanged = () => {}}) { const scope = createScope(); let active = null, retryVersion = 0; const dispose = () => scope.dispose(); @@ -36,7 +41,7 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle active?.close(false); const {items,selectedKey,trigger = document.activeElement} = options; let mounted = false, initial, entry, currentKey, viewer = null, withoutViewer = false, mediaScope = null, closed = false; - let originalDownload = null, inspectionState = inspectionController.state; + let originalDownload = null, inspectionState = inspectionController.state, collectionChanges = null; const session = createViewerSession({adapter,items,initialKey:selectedKey,onChange:(state,selected) => { if (!mounted) { initial = [state,selected]; return; } update(state,selected); @@ -99,7 +104,11 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle const localSender = {open(roles,trigger) {session.stop();sender.open(roles,trigger);}}; const inspector = mountFileInspector(details,{api,sender:localSender,parentScope:local,variant:'full',onInteraction:() => session.stop(),onRetry:() => inspectionController.retry(), onLoad:() => inspectionController.load?.(), - onRefresh:onRefresh ? () => { closeViewer();onRefresh(); } : undefined}); + onRefresh:onRefresh ? () => { closeViewer();onRefresh(); } : undefined,collections, + onCollectionsChanged:({added,removed}) => { + collectionChanges ??= {added:[],removed:[]}; + collectionChanges.added.push(...added);collectionChanges.removed.push(...removed); + }}); function closeViewer(restore = true) { if (closed) return; closed = true;active = null;local.dispose(); @@ -108,6 +117,7 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle else onRestoreFocus?.(entry); } if (!scope.signal.aborted) onClose?.(); + if (collectionChanges && !scope.signal.aborted) onCollectionsChanged(collectionChanges); } active = {close:closeViewer,stop:() => session.stop()}; local.cleanup(() => {