diff --git a/tests/browser/workspace-collections.spec.mjs b/tests/browser/workspace-collections.spec.mjs index 49fb3e3b..e731d599 100644 --- a/tests/browser/workspace-collections.spec.mjs +++ b/tests/browser/workspace-collections.spec.mjs @@ -330,3 +330,193 @@ test('renaming a collection deleted in another tab says so and shows the deleted await expect(description(page)).toHaveText('This collection no longer exists.'); await expect(rows(page)).toHaveCount(1); }); + +// #331 D138 (design §7): the selection bar's Add to collection and Remove from collection. +const bar = page => page.locator('#bulkbar > .bulkbar'); +const barButton = (page, name) => bar(page).getByRole('button', { name, exact: true }); +const addDialog = page => page.getByRole('dialog', { name: 'Add selected images to collection', exact: true }); +const addField = page => addDialog(page).getByRole('combobox', { name: 'Collection name', exact: true }); +const check = (page, file) => page.getByRole('checkbox', { name: `Select ${file}`, exact: true }).check(); +const card = (page, path) => page.locator(`article[data-image-key$=":${path}"]`); + +/** Add the checked images under `name` through the bar's dialog. */ +async function addChecked(page, name) { + await barButton(page, 'Add to collection').click(); + await expect(addDialog(page)).toBeVisible(); + await addField(page).fill(name); + await addDialog(page).getByRole('button', { name: 'Add', exact: true }).click(); + await expect(addDialog(page)).toHaveCount(0); +} + +test('Add to collection creates a collection and adds to it ignoring case (CUJ 1–3)', async ({ page, request }) => { + await seed(request); + await page.goto(`${workspace}#/images`); + await expect(imageCards(page)).toHaveCount(21); + for (const file of ['loose.png', 'landscape_01.png', 'experiment_01.png']) await check(page, file); + await expect(bar(page).locator('button')).toHaveText(['Add tags', 'Auto-tag selected', 'Set category', 'Add to collection', + 'Export metadata', 'Clear']); + await barButton(page, 'Add to collection').click(); + // The concept's simpleInput with Add (D138) and the existing names as suggestions. + await expect(addDialog(page).locator('label')).toHaveText('Collection name'); + await expect(addField(page)).toHaveValue(''); + await expect(addField(page)).toHaveAttribute('maxlength', '100'); + await expect(addDialog(page).locator('datalist option')).toHaveCount(2); + expect(await addDialog(page).locator('datalist option').evaluateAll(options => options.map(option => option.value))) + .toEqual(['Experiments', 'Landscapes']); + await expect(addDialog(page).locator('.dialog-actions button')).toHaveText(['Cancel', 'Add']); + // An empty name keeps the dialog on its field. + await addDialog(page).getByRole('button', { name: 'Add', exact: true }).click(); + await expect(addField(page)).toBeFocused(); + await addField(page).fill(' LoRa testing '); + await addDialog(page).getByRole('button', { name: 'Add', exact: true }).click(); + await expect(toast(page)).toHaveText('Added 3 images to “LoRa testing”.'); + await expect(bar(page).locator('strong')).toHaveText('3 selected'); + await expect(row(page, 'LoRa testing').locator('.nav-count')).toHaveText('3'); + await expect(rows(page)).toHaveCount(3); + + // CUJ 2: one of two already in it. + await bar(page).getByRole('button', { name: 'Clear', exact: true }).click(); + await check(page, 'loose.png'); + await check(page, 'landscape_02.png'); + await addChecked(page, 'LoRa testing'); + await expect(toast(page)).toHaveText('Added 1 image to “LoRa testing”. 1 was already in it.'); + await expect(row(page, 'LoRa testing').locator('.nav-count')).toHaveText('4'); + + // CUJ 3: a case variant joins the same collection. + await bar(page).getByRole('button', { name: 'Clear', exact: true }).click(); + await check(page, 'landscape_03.png'); + await addChecked(page, 'lora TESTING'); + await expect(toast(page)).toHaveText('Added 1 image to “LoRa testing”.'); + await expect(rows(page)).toHaveCount(3); + await expect(row(page, 'LoRa testing').locator('.nav-count')).toHaveText('5'); + const created = (await listed(request)).find(item => item.name === 'LoRa testing'); + expect(created).toMatchObject({ count: 5, missing: 0 }); + + // Everything already in it. + await addChecked(page, 'LoRa testing'); + await expect(toast(page)).toHaveText('All selected images were already in “LoRa testing”.'); +}); + +test('Remove from collection takes the checked images off the open collection (CUJ 5)', async ({ page, request }) => { + const manifest = await seed(request); + const { id } = manifest.collections.Landscapes; + await page.goto(`${workspace}#/collection/${id}`); + await expect(imageCards(page)).toHaveCount(12); + await check(page, 'landscape_01.png'); + await check(page, 'landscape_02.png'); + await expect(bar(page).locator('button')).toHaveText(['Add tags', 'Auto-tag selected', 'Set category', 'Add to collection', + 'Remove from collection', 'Export metadata', 'Clear']); + await barButton(page, 'Remove from collection').click(); + await expect(toast(page)).toHaveText('Removed 2 images from “Landscapes”.'); + await expect(imageCards(page)).toHaveCount(10); + await expect(card(page, 'collections/landscape_01.png')).toHaveCount(0); + // The removed files left the listing, so the bar has nothing checked. + await expect(bar(page)).toHaveCount(0); + await expect(row(page, 'Landscapes').locator('.nav-count')).toHaveText('10'); + // A checked file still listed stays checked across the reload. + await check(page, 'landscape_03.png'); + await check(page, 'landscape_04.png'); + await page.route(`**${API}/items`, async route => { + if (route.request().method() !== 'DELETE') return route.fallback(); + // Remove only landscape_04; landscape_03 stays listed and checked. + const body = route.request().postDataJSON(); + await route.continue({ postData: JSON.stringify({ ...body, items: body.items.filter(item => item.path.endsWith('_04.png')) }) }); + }); + await barButton(page, 'Remove from collection').click(); + await expect(toast(page)).toHaveText('Removed 1 image from “Landscapes”.'); + await expect(imageCards(page)).toHaveCount(9); + await expect(bar(page).locator('strong')).toHaveText('1 selected'); + await expect(page.getByRole('checkbox', { name: 'Select landscape_03.png', exact: true })).toBeChecked(); + // No file changed: the plain Images page still lists all of them. + await page.goto(`${workspace}#/images`); + await expect(imageCards(page)).toHaveCount(21); +}); + +test('Images without a collection filter has no Remove from collection', async ({ page, request }) => { + const manifest = await seed(request); + await page.goto(`${workspace}#/images`); + await check(page, 'loose.png'); + await expect(barButton(page, 'Add to collection')).toBeVisible(); + await expect(barButton(page, 'Remove from collection')).toHaveCount(0); + // A saved view on a collection has its filter active: Remove shows (design §5). + await page.goto(`${workspace}#/saved/${manifest.saved_view.id}`); + await expect(imageCards(page)).toHaveCount(8); + await check(page, 'experiment_01.png'); + await barButton(page, 'Remove from collection').click(); + await expect(toast(page)).toHaveText('Removed 1 image from “Experiments”.'); + await expect(imageCards(page)).toHaveCount(7); +}); + +test('a failed add keeps the selection; a stale source reloads the images', async ({ page, request }) => { + await seed(request); + await page.goto(`${workspace}#/images`); + await expect(imageCards(page)).toHaveCount(21); + let reply = { status: 500, body: { success: false, error: 'The collection could not be changed.' } }; + await page.route(`**${API}/items`, route => route.fulfill({ status: reply.status, contentType: 'application/json', + body: JSON.stringify(reply.body) })); + await check(page, 'loose.png'); + await check(page, 'landscape_05.png'); + await addChecked(page, 'Grids'); + await expect(toast(page)).toHaveText('The images could not be added. Try again.'); + await expect(bar(page).locator('strong')).toHaveText('2 selected'); + await expect(rows(page)).toHaveCount(2); + + reply = { status: 404, body: { success: false, code: 'files-not-found', error: 'x' } }; + await addChecked(page, 'Grids'); + await expect(toast(page)).toHaveText('None of the selected images could be found. Refresh the images and try again.'); + await expect(bar(page).locator('strong')).toHaveText('2 selected'); + + reply = { status: 409, body: { success: false, code: 'source-changed', error: 'x' } }; + const reloaded = page.waitForRequest(request => request.url().includes('/prompt_manager/gallery/subfolders')); + await addChecked(page, 'Grids'); + await expect(toast(page)).toHaveText('The sources changed. Refresh the images and try again.'); + await reloaded; + await expect(imageCards(page)).toHaveCount(21); + await expect(bar(page)).toHaveCount(0); +}); + +test('more than 500 or an unloaded "all matching" selection is refused with the cap', async ({ page, request }) => { + await seed(request); + await page.goto(`${workspace}#/images`); + await expect(imageCards(page)).toHaveCount(21); + // Every loaded image checked and the listing says more match: "Select all N matching" (#392, D135). + await page.route('**/prompt_manager/images/output?*', async route => { + // A next page never answers, so the listing stays at its loaded 21 of 900. + if (new URL(route.request().url()).searchParams.has('cursor')) return; + const response = await route.fetch(); + const data = await response.json(); + await route.fulfill({ response, json: { ...data, total: 900, has_more: true, next_cursor: 'more' } }); + }); + await page.reload(); + await expect(imageCards(page)).toHaveCount(21); + await page.locator('#image-grid .image-open').first().focus(); + await page.keyboard.press('ControlOrMeta+a'); + await bar(page).getByRole('button', { name: 'Select all 900 matching', exact: true }).click(); + await expect(bar(page).locator('strong')).toHaveText('All 900 matching selected'); + await barButton(page, 'Add to collection').click(); + await expect(toast(page)).toHaveText('Select at most 500 images.'); + await expect(addDialog(page)).toHaveCount(0); +}); + +for (const theme of ['dark', 'light']) { + test(`the Add to collection 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 check(page, 'loose.png'); + await expect(barButton(page, 'Add to collection')).toBeVisible(); + await barButton(page, 'Add to collection').click(); + await expect(addDialog(page)).toBeVisible(); + const fits = await addDialog(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); + await addField(page).fill('Phone'); + await addDialog(page).getByRole('button', { name: 'Add', exact: true }).click(); + await expect(toast(page)).toHaveText('Added 1 image to “Phone”.'); + await expect(row(page, 'Phone')).toBeVisible(); + }); +} diff --git a/tests/browser/workspace-images.spec.mjs b/tests/browser/workspace-images.spec.mjs index f088e149..c493301c 100644 --- a/tests/browser/workspace-images.spec.mjs +++ b/tests/browser/workspace-images.spec.mjs @@ -1787,7 +1787,7 @@ test('card checkboxes select images into the concept bar and Clear empties it', await checkbox(first, 'same.png').check(); await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('1 selected'); // D85: Add tags (the concept's) and Auto-tag selected lead the real actions. - await expect(barButtons(page)).toHaveText(['Add tags', 'Auto-tag selected', 'Set category', 'Export metadata', 'Clear']); + await expect(barButtons(page)).toHaveText(['Add tags', 'Auto-tag selected', 'Set category', 'Add to collection', 'Export metadata', 'Clear']); // Toggling neither opens the viewer, nor inspects, nor moves focus. await expect(page.locator('dialog[open]')).toHaveCount(0); await expect(checkbox(first, 'same.png')).toBeFocused(); diff --git a/tests/browser/workspace-tagging-bulk.spec.mjs b/tests/browser/workspace-tagging-bulk.spec.mjs index 01bbda1d..5dab77d0 100644 --- a/tests/browser/workspace-tagging-bulk.spec.mjs +++ b/tests/browser/workspace-tagging-bulk.spec.mjs @@ -73,7 +73,7 @@ const jobStart = page => page.waitForRequest(item => item.method() === 'POST' && test('selection bar shows Add tags then Auto-tag selected before Set category', async ({ page }) => { await select(page, FIRST); - await expect(bar(page).locator(':scope > *')).toHaveText(['1 selected', 'Add tags', 'Auto-tag selected', 'Set category', 'Export metadata', 'Clear']); + await expect(bar(page).locator(':scope > *')).toHaveText(['1 selected', 'Add tags', 'Auto-tag selected', 'Set category', 'Add to collection', 'Export metadata', 'Clear']); }); test('Add tags runs a job and the cards show the tags after it finishes', async ({ page, request }) => { diff --git a/tests/js/workspace-images-selection.test.js b/tests/js/workspace-images-selection.test.js new file mode 100644 index 00000000..4590bc4c --- /dev/null +++ b/tests/js/workspace-images-selection.test.js @@ -0,0 +1,285 @@ +// The Images selection bar's collection actions (#331 D138, design §7): Add to collection after Set category, Remove +// from collection beside it while a collection filter is active, the 500 cap and every toast of §7.4. +import test from 'node:test'; +import assert from 'node:assert/strict'; + +// Guarded: before the module exists every test fails on an assertion, not on the import. +const loaded = await import('../../web/js/workspace/images-collections.mjs').catch(() => null); +const module = () => { assert.ok(loaded, 'images-collections.mjs is not implemented'); return loaded; }; +const { mountSelectionBar } = await import('../../web/js/workspace/images-selection.mjs'); +const { createScope } = await import('../../web/js/shared/toolkit/core.js'); +const strings = await import('../../web/js/workspace/collections.mjs'); + +const SCOPE = 'b'.repeat(64); +const image = (path, extra = {}) => ({ root_index: 0, root_scope: SCOPE, path, filename: path.split('/').pop(), ...extra }); +const target = path => ({ root_index: 0, root_scope: SCOPE, path }); +const images = count => Array.from({ length: count }, (_, index) => image(`set/${index}.png`)); +const keyOf = item => `${item.root_index}:${item.path}`; +const flush = () => new Promise(resolve => setTimeout(resolve, 0)); +const coded = (code, status = 400) => Object.assign(new Error(code), { name: 'ApiError', code, status }); + +/** A minimal DOM for the selection bar: elements with order, attributes, listeners, focus and a toast. */ +function fakeDOM() { + const previous = globalThis.document; + let active = null; + class Element extends EventTarget { + constructor(tag) { + super(); + this.tagName = tag.toUpperCase(); + this.children = []; + this.parentNode = null; + this.attributes = {}; + this.dataset = {}; + this.className = ''; + this.id = ''; + this._text = ''; + } + get textContent() { return this._text + this.children.map(child => child.textContent).join(''); } + set textContent(text) { this.children = []; this._text = String(text); } + get nextElementSibling() { + const siblings = this.parentNode?.children ?? []; + return siblings[siblings.indexOf(this) + 1] ?? null; + } + append(...nodes) { + for (const node of nodes) { node.remove(); node.parentNode = this; this.children.push(node); } + } + after(node) { + node.remove(); + node.parentNode = this.parentNode; + this.parentNode.children.splice(this.parentNode.children.indexOf(this) + 1, 0, node); + } + remove() { + if (!this.parentNode) return; + this.parentNode.children.splice(this.parentNode.children.indexOf(this), 1); + this.parentNode = null; + } + setAttribute(name, value) { this.attributes[name] = String(value); } + getAttribute(name) { return Object.hasOwn(this.attributes, name) ? this.attributes[name] : null; } + removeAttribute(name) { delete this.attributes[name]; } + querySelector() { return this.children.find(child => child.tagName === 'BUTTON' && child.getAttribute('aria-disabled') !== 'true') ?? null; } + querySelectorAll() { return []; } + focus() { active = this; } + click() { this.dispatchEvent(new Event('click')); } + } + const toast = new Element('div'); + const body = new Element('body'); + globalThis.document = { + body, + get activeElement() { return active ?? body; }, + createElement: tag => new Element(tag), + createTextNode: text => Object.assign(new Element('#text'), { _text: String(text) }), + getElementById: id => (id === 'workspace-toast' ? toast : null), + }; + return { Element, body, toast, restore() { globalThis.document = previous; } }; +} + +/** The bar over `items` with `checked` keys; `options` adds the collection props. */ +function mountBar(dom, items, checkedKeys, options = {}) { + const scope = createScope(); + const grid = new dom.Element('div'); + const focusFallback = new dom.Element('h1'); + const bar = mountSelectionBar({ scope, api: {}, grid, getItems: () => items, focusFallback, onCategorySet() {}, + activity: options.activity ?? null, onTagged() {}, getFilter: () => ({}), + getListing: () => ({ total: options.total ?? items.length, hasMore: false, positiveCount: null }), + checked: Object.freeze(new Set(checkedKeys)), ...options.props }); + return { scope, bar, labels: () => bar.element.children[0]?.children.map(child => child.textContent) ?? [], + button: text => bar.element.children[0].children.find(child => child.textContent === text) }; +} + +function store(overrides = {}) { + const calls = []; + return { + calls, + collections: [{ id: 3, name: 'Experiments', count: 8, missing: 0 }, { id: 7, name: 'Landscapes', count: 12, missing: 1 }], + add: async (where, items) => { calls.push(['add', where, items]); return overrides.add?.(where, items); }, + remove: async (id, items) => { calls.push(['remove', id, items]); return overrides.remove?.(id, items); }, + }; +} + +test('selection bar lists Add to collection after Set category', () => { + const dom = fakeDOM(); + try { + const items = images(2); + const collections = store(); + const plain = mountBar(dom, items, [keyOf(items[0])], { activity: {}, props: { collections } }); + assert.deepEqual(plain.labels(), ['1 selected', 'Add tags', 'Auto-tag selected', 'Set category', 'Add to collection', + 'Export metadata', 'Clear']); + plain.scope.dispose(); + // §7.1: Remove from collection beside Add, only while a collection filter is active. + const filtered = mountBar(dom, items, [keyOf(items[0])], { activity: {}, props: { collections, collection: '7' } }); + assert.deepEqual(filtered.labels(), ['1 selected', 'Add tags', 'Auto-tag selected', 'Set category', 'Add to collection', + 'Remove from collection', 'Export metadata', 'Clear']); + filtered.scope.dispose(); + // Without the client store (a page that has none) neither shows. + const none = mountBar(dom, items, [keyOf(items[0])]); + assert.deepEqual(none.labels(), ['1 selected', 'Set category', 'Export metadata', 'Clear']); + none.scope.dispose(); + } finally { dom.restore(); } +}); + +test('more than 500 checked toasts and opens nothing', async () => { + const dom = fakeDOM(); + try { + const items = images(501); + const collections = store(); + const view = mountBar(dom, items, items.map(keyOf), { props: { collections, collection: '7' } }); + view.button('Add to collection').click(); + assert.equal(dom.toast.textContent, 'Select at most 500 images.'); + assert.equal(dom.body.children.length, 0, 'no dialog opened'); + dom.toast.textContent = ''; + view.button('Remove from collection').click(); + await flush(); + assert.equal(dom.toast.textContent, 'Select at most 500 images.'); + assert.deepEqual(collections.calls, []); + view.scope.dispose(); + } finally { dom.restore(); } +}); + +test('Remove from collection removes the checked files and reloads the listing', async () => { + const dom = fakeDOM(); + try { + const items = images(3); + let changed = 0; + const collections = store({ remove: () => ({ collection: { id: 7, name: 'Landscapes' }, removed: 2 }) }); + const view = mountBar(dom, items, [keyOf(items[0]), keyOf(items[2])], { props: { collections, collection: '7', + onCollectionsChanged: () => { changed++; } } }); + const remove = view.button('Remove from collection'); + remove.click(); + // A second press while the first is in flight is ignored. + assert.equal(remove.getAttribute('aria-disabled'), 'true'); + remove.click(); + await flush(); + assert.deepEqual(collections.calls, [['remove', 7, [target('set/0.png'), target('set/2.png')]]]); + assert.equal(dom.toast.textContent, 'Removed 2 images from “Landscapes”.'); + assert.equal(changed, 1); + assert.equal(remove.getAttribute('aria-disabled'), null); + view.scope.dispose(); + } finally { dom.restore(); } +}); + +test('the collection-target rule keeps the all-matching selection to its loaded images', () => { + const { collectionTargets } = module(); + assert.deepEqual(collectionTargets(images(2)), { targets: [target('set/0.png'), target('set/1.png')] }); + assert.deepEqual(collectionTargets(images(501)), { refusal: strings.TOO_MANY_TOAST }); + // All N matching: only when every match is loaded and checked, and at most 500. + assert.deepEqual(collectionTargets(images(2), { allMatching: true, total: 2 }), { targets: [target('set/0.png'), target('set/1.png')] }); + assert.deepEqual(collectionTargets(images(2), { allMatching: true, total: 3 }), { refusal: strings.TOO_MANY_TOAST }); + assert.deepEqual(collectionTargets(images(501), { allMatching: true, total: 501 }), { refusal: strings.TOO_MANY_TOAST }); + // A file without a listing identity cannot be named; none left is "could not be found". + assert.deepEqual(collectionTargets([{ ...image('x.png'), root_scope: 'nope' }]), { refusal: strings.FILES_NOT_FOUND_TOAST }); +}); + +const pageScope = () => { + const controller = new AbortController(); + return { signal: controller.signal, cleanup() {}, abort: () => controller.abort() }; +}; + +test('Add to collection opens the concept dialog with the existing names as suggestions', () => { + const { openAddToCollection } = module(); + let dialog = null; + const collections = store(); + openAddToCollection({ scope: pageScope(), collections, images: images(1), trigger: 'trigger', notify: assert.fail, + openInput: args => { dialog = args; } }); + assert.equal(dialog.title, 'Add selected images to collection'); + assert.equal(dialog.label, 'Collection name'); + assert.equal(dialog.action, 'Add'); + assert.equal(dialog.maxLength, 100); + assert.equal(dialog.trigger, 'trigger'); + assert.deepEqual(dialog.suggestions, ['Experiments', 'Landscapes']); + assert.equal(dialog.value ?? '', ''); +}); + +/** Apply `name` in the Add dialog over `count` images; resolves with the toasts and the store's calls. */ +async function add(name, answer, { count = 1, extra = {} } = {}) { + const { openAddToCollection } = module(); + const toasts = []; + let dialog = null, sourceChanged = 0; + const collections = store({ add: answer }); + openAddToCollection({ scope: pageScope(), collections, images: images(count), notify: text => toasts.push(text), + openInput: args => { dialog = args; }, onSourceChanged: () => { sourceChanged++; }, ...extra }); + await dialog.onApply(name); + return { toasts, calls: collections.calls, sourceChanged }; +} +const answer = (added, already, notFound, created = false) => () => ({ collection: { id: 9, name: 'LoRa testing' }, created, + added, already, notFound }); + +test('Add sends the normalized name and the checked files', async () => { + const { toasts, calls } = await add(' LoRa testing ', answer(3, 0, 0, true), { count: 3 }); + assert.deepEqual(calls, [['add', { name: 'LoRa testing' }, [target('set/0.png'), target('set/1.png'), target('set/2.png')]]]); + assert.deepEqual(toasts, ['Added 3 images to “LoRa testing”.']); +}); + +test('Add toasts every outcome of §7.4', async () => { + const cases = [ + [answer(1, 0, 0), 'Added 1 image to “LoRa testing”.'], + [answer(1, 1, 0), 'Added 1 image to “LoRa testing”. 1 was already in it.'], + [answer(2, 3, 0), 'Added 2 images to “LoRa testing”. 3 were already in it.'], + [answer(1, 0, 1), 'Added 1 image to “LoRa testing”. 1 could not be found.'], + [answer(1, 2, 4), 'Added 1 image to “LoRa testing”. 2 were already in it. 4 could not be found.'], + [answer(0, 2, 0), 'All selected images were already in “LoRa testing”.'], + [answer(0, 1, 1), 'Added 0 images to “LoRa testing”. 1 was already in it. 1 could not be found.'], + [() => { throw coded('files-not-found', 404); }, 'None of the selected images could be found. Refresh the images and try again.'], + [() => { throw coded('collection-not-found', 404); }, 'That collection no longer exists.'], + [() => { throw new TypeError('offline'); }, 'The images could not be added. Try again.'], + ]; + for (const [reply, text] of cases) assert.deepEqual((await add('LoRa testing', reply)).toasts, [text], text); +}); + +test('a stale source on Add says so and reloads the listing', async () => { + const { toasts, sourceChanged } = await add('Grids', () => { throw coded('source-changed', 409); }); + assert.deepEqual(toasts, ['The sources changed. Refresh the images and try again.']); + assert.equal(sourceChanged, 1); +}); + +test('Add with a name the store cannot take sends nothing and says it failed', async () => { + const { toasts, calls } = await add('bad\u0001name', answer(1, 0, 0)); + assert.deepEqual(calls, []); + assert.deepEqual(toasts, ['The images could not be added. Try again.']); +}); + +test('Add after the page left shows no toast', async () => { + const { openAddToCollection } = module(); + const scope = pageScope(); + const toasts = []; + let dialog = null; + openAddToCollection({ scope, collections: store({ add: answer(1, 0, 0) }), images: images(1), notify: text => toasts.push(text), + openInput: args => { dialog = args; } }); + scope.abort(); + await dialog.onApply('Grids'); + assert.deepEqual(toasts, []); +}); + +test('Add refuses an over-cap or all-matching selection before any dialog', () => { + const { openAddToCollection } = module(); + const toasts = []; + const open = extra => openAddToCollection({ scope: pageScope(), collections: store(), notify: text => toasts.push(text), + openInput: () => assert.fail('no dialog opens'), ...extra }); + open({ images: images(501) }); + open({ images: images(2), allMatching: true, total: 40 }); + assert.deepEqual(toasts, [strings.TOO_MANY_TOAST, strings.TOO_MANY_TOAST]); +}); + +/** Remove `count` images from collection 7 with `reply`; resolves with the toasts and callbacks. */ +async function remove(reply, count = 1) { + const { removeFromCollection } = module(); + const toasts = []; + let changed = 0, sourceChanged = 0; + const collections = store({ remove: reply }); + await removeFromCollection({ scope: pageScope(), collections, collectionId: 7, images: images(count), + notify: text => toasts.push(text), onChanged: () => { changed++; }, onSourceChanged: () => { sourceChanged++; } }); + return { toasts, changed, sourceChanged, calls: collections.calls }; +} + +test('Remove toasts every outcome of §7.4', async () => { + assert.deepEqual(await remove(() => ({ collection: { id: 7, name: 'Landscapes' }, removed: 1 })), + { toasts: ['Removed 1 image from “Landscapes”.'], changed: 1, sourceChanged: 0, calls: [['remove', 7, [target('set/0.png')]]] }); + assert.deepEqual((await remove(() => ({ collection: { id: 7, name: 'Landscapes' }, removed: 3 }), 3)).toasts, + ['Removed 3 images from “Landscapes”.']); + // A failure keeps the listing; a deleted collection reloads it (the page then says the collection is gone). + const failed = await remove(() => { throw new TypeError('offline'); }); + assert.deepEqual([failed.toasts, failed.changed], [['The images could not be removed. Try again.'], 0]); + const gone = await remove(() => { throw coded('collection-not-found', 404); }); + assert.deepEqual([gone.toasts, gone.changed], [['That collection no longer exists.'], 1]); + const stale = await remove(() => { throw coded('source-changed', 409); }); + assert.deepEqual([stale.toasts, stale.changed, stale.sourceChanged], [['The sources changed. Refresh the images and try again.'], 0, 1]); +}); diff --git a/tests/js/workspace-toolbar.test.js b/tests/js/workspace-toolbar.test.js index 761c2c6e..5de3954d 100644 --- a/tests/js/workspace-toolbar.test.js +++ b/tests/js/workspace-toolbar.test.js @@ -567,6 +567,36 @@ test('an input dialog with a fallback applies it for an empty value', async () = } }); +// #331 D138: Add to collection offers the existing names through a datalist linked to the field. +test('an input dialog with suggestions links a datalist that leaves with the dialog', async () => { + const { openInputDialog, closeWorkspaceDialog } = await dialogsModule; + const dom = fakeDOM(); + const scope = (await coreModule).createScope(); + try { + openInputDialog({ scope, title: 'Add selected images to collection', label: 'Collection name', action: 'Add', + maxLength: 100, suggestions: ['Landscapes', 'Experiments'], onApply() {} }); + const [field] = byId(dom, 'modal-body').children; + assert.deepEqual(tags(field), ['label', 'input', 'datalist']); + const [, input, list] = field.children; + assert.equal(list.id, 'action-input-suggestions'); + assert.equal(input.getAttribute('list'), 'action-input-suggestions'); + assert.deepEqual(list.children.map(option => [option.tagName, option.value]), [['OPTION', 'Landscapes'], ['OPTION', 'Experiments']]); + // The next dialog without suggestions has no list: the datalist went with the replaced body. + openInputDialog({ scope, title: 'Save this view', label: 'View name', action: 'Save', onApply() {} }); + assert.equal(dom.all(node => node.tagName === 'DATALIST').length, 0); + assert.equal(byId(dom, 'action-input').getAttribute('list'), null); + // An empty list adds nothing either; a non-text suggestion is refused. + openInputDialog({ scope, title: 'T', label: 'L', action: 'A', suggestions: [], onApply() {} }); + assert.equal(dom.all(node => node.tagName === 'DATALIST').length, 0); + assert.throws(() => openInputDialog({ scope, title: 'T', label: 'L', action: 'A', suggestions: [1], onApply() {} }), TypeError); + assert.throws(() => openInputDialog({ scope, title: 'T', label: 'L', action: 'A', suggestions: 'A', onApply() {} }), TypeError); + } finally { + closeWorkspaceDialog(); + scope.dispose(); + dom.restore(); + } +}); + test('dialogs restore focus to the trigger on Escape', async () => { const { openRefineDialog, openImportDialog, openCopyFallback, closeWorkspaceDialog } = await dialogsModule; const dom = fakeDOM(); diff --git a/web/js/workspace/images-collections.mjs b/web/js/workspace/images-collections.mjs new file mode 100644 index 00000000..939a6f5e --- /dev/null +++ b/web/js/workspace/images-collections.mjs @@ -0,0 +1,76 @@ +/** + * The Images selection bar's collection actions (#331 D138, design §7.2–7.4): + * + * - **Add to collection** (the concept's `bulk-collection` simpleInput, app.js a1392–1398): "Add selected images to + * collection", "Collection name", Add, with the existing names offered as suggestions. A name that matches a + * collection ignoring case adds to it; a new name creates it. The selection stays and the listing is not reloaded + * (an add never changes it). + * - **Remove from collection** (beyond the concept, only while a collection filter is active): no confirmation; the + * listing then reloads through `onChanged`, keeping the checked files still listed. + * + * The routes take at most 500 explicit files. "All N matching" (#392, D135) is accepted only when every match is + * loaded and checked and there are at most 500 of them; otherwise the action toasts "Select at most 500 images." and + * sends nothing (ruling for #331 PR 3: the collection routes have no filter body). + */ +import { bulkTargets } from './images-tagging-bulk.mjs'; +import { ADD_DIALOG_ACTION, ADD_DIALOG_LABEL, ADD_DIALOG_TITLE, ADD_FAILED_TOAST, FILES_NOT_FOUND_TOAST, MAX_COLLECTION_NAME_LENGTH, + REMOVE_FAILED_TOAST, TOO_MANY_TOAST, addedMessage, isCollectionGone, normalizeCollectionName, removedMessage, + writeFailure } from './collections.mjs'; +import { openInputDialog } from './workspace-dialogs.mjs'; +import { showWorkspaceToast } from './toast.mjs'; + +/** + * The files an action sends for the checked `images` (listing order): `{targets}`, or `{refusal}` with the toast. + * With `allMatching`, `total` is the listing's match count; only a fully loaded match is sent. + */ +export function collectionTargets(images, { allMatching = false, total = images.length } = {}) { + if (allMatching && total !== images.length) return { refusal: TOO_MANY_TOAST }; + const targets = bulkTargets(images); + if (!targets) return { refusal: TOO_MANY_TOAST }; + return targets.length ? { targets } : { refusal: FILES_NOT_FOUND_TOAST }; +} + +/** + * Add to collection over `images` (the checked files, taken now). `collections` is the client store; `openInput` is + * the dialog seam. A stale source (409) calls `onSourceChanged()` after its toast, so the page reloads the listing. + */ +export function openAddToCollection({ scope, collections, images, trigger = null, allMatching = false, total = images.length, + notify = showWorkspaceToast, openInput = openInputDialog, onSourceChanged = () => {} }) { + const { targets, refusal } = collectionTargets(images, { allMatching, total }); + if (refusal) { notify(refusal); return; } + openInput({ scope, trigger, title: ADD_DIALOG_TITLE, label: ADD_DIALOG_LABEL, action: ADD_DIALOG_ACTION, + maxLength: MAX_COLLECTION_NAME_LENGTH, suggestions: collections.collections.map(item => item.name), + onApply: async text => { + const name = normalizeCollectionName(text); + if (name === null) { notify(ADD_FAILED_TOAST); return; } + try { + const result = await collections.add({ name }, targets); + if (!scope.signal.aborted) notify(addedMessage(result)); + } catch (error) { + if (scope.signal.aborted) return; + notify(writeFailure(error, ADD_FAILED_TOAST)); + if (error?.code === 'source-changed') onSourceChanged(); + } + } }); +} + +/** + * Remove the checked `images` from the open collection `collectionId`. Resolves once the toast is shown; a confirmed + * remove, or a collection found deleted meanwhile, calls `onChanged()` so the page lists it again. + */ +export async function removeFromCollection({ scope, collections, collectionId, images, allMatching = false, total = images.length, + notify = showWorkspaceToast, onChanged = () => {}, onSourceChanged = () => {} }) { + const { targets, refusal } = collectionTargets(images, { allMatching, total }); + if (refusal) { notify(refusal); return; } + try { + const result = await collections.remove(collectionId, targets); + if (scope.signal.aborted) return; + notify(removedMessage(result.collection.name, result.removed)); + onChanged(); + } catch (error) { + if (scope.signal.aborted) return; + notify(writeFailure(error, REMOVE_FAILED_TOAST)); + if (isCollectionGone(error)) onChanged(); + else if (error?.code === 'source-changed') onSourceChanged(); + } +} diff --git a/web/js/workspace/images-selection.mjs b/web/js/workspace/images-selection.mjs index ee5cd69c..6031fa71 100644 --- a/web/js/workspace/images-selection.mjs +++ b/web/js/workspace/images-selection.mjs @@ -7,6 +7,7 @@ import { openInputDialog } from './workspace-dialogs.mjs'; import { MAX_FILTER_IMAGES, TOO_MANY_MATCHING, openAddTags, openAutoTag } from './images-tagging-bulk.mjs'; import { showWorkspaceToast } from './toast.mjs'; import { applyKeys, createLongPress, matchingLabel, matchingOffer, offersAllMatching, rangeKeys } from './images-multiselect.mjs'; +import { openAddToCollection, removeFromCollection } from './images-collections.mjs'; const CHECKBOX = '.image-overlays input[type="checkbox"]'; const CHECKBOX_LABEL = '.image-overlays > label'; @@ -41,9 +42,14 @@ function el(tag, text, className) { * `clear` serve the grid's keys (images.mjs). `getListing()` gives `{total, hasMore, positiveCount}` and * `getFilter()` the listing filter: once every loaded image is checked and more match, the bar offers "Select all N * matching", whose actions send that filter to the server. + * + * Collections (#331 D138, design §7): with the client store `collections`, Add to collection follows Set category; + * while a collection filter is active (`collection`, its id) Remove from collection follows it, and a confirmed remove + * calls `onCollectionsChanged()` to reload the listing. A stale source on either calls `onSourceChanged()`. */ export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, onCategorySet, activity = null, onTagged, - getListing, getFilter, checked: initial = Object.freeze(new Set()) }) { + getListing, getFilter, checked: initial = Object.freeze(new Set()), collections = null, collection = null, + onCollectionsChanged = () => {}, onSourceChanged = () => {} }) { let checked = initial; let exporting = false, categorizing = false; // D135: the range anchor, "all N matching" mode, touch selection mode and a pending Shift+click on a checkbox. @@ -74,7 +80,19 @@ export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, o ...matchingTarget() })); bulk.append(addTags, autoTag); } - bulk.append(setCategory, exportMetadata, clearButton); + bulk.append(setCategory); + if (collections) { + const addToCollection = createButton('Add to collection'); + scope.listen(addToCollection, 'click', () => openAddToCollection({ scope, collections, images: checkedImages(), + trigger: addToCollection, ...collectionSelection(), onSourceChanged })); + bulk.append(addToCollection); + } + if (collections && collection) { + const removeButton = createButton('Remove from collection'); + scope.listen(removeButton, 'click', () => removeChecked(removeButton)); + bulk.append(removeButton); + } + bulk.append(exportMetadata, clearButton); /** The count updates in place: the same bar and `strong`, its text written only when it changes. */ function render() { @@ -142,6 +160,24 @@ export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, o function matchingTarget() { return allMatching ? { filter: getFilter(), count: getListing().total } : {}; } + /** The collection actions' view of "all N matching" (#331 ruling): the loaded images and the match count. */ + function collectionSelection() { + return { allMatching, total: allMatching ? getListing().total : checked.size }; + } + /** Remove from collection: one write at a time; the selection stays (a reload keeps what is still listed). */ + let removing = false; + async function removeChecked(button) { + if (removing) return; + removing = true; + const release = busy(button); + try { + await removeFromCollection({ scope, collections, collectionId: Number(collection), images: checkedImages(), + ...collectionSelection(), onChanged: onCollectionsChanged, onSourceChanged }); + } finally { + removing = false; + release(); + } + } /** Toggle one image on its own; it becomes the anchor. */ function toggle(key, on = !isChecked(key)) { settleMatching(); diff --git a/web/js/workspace/images.mjs b/web/js/workspace/images.mjs index 4ea59afe..aaa13d9c 100644 --- a/web/js/workspace/images.mjs +++ b/web/js/workspace/images.mjs @@ -102,10 +102,12 @@ export function selectedImage() { * Images last visit. `collection` (an id) is a collection page (#331 D137): only its members are listed, favorites is * off, and it neither restores nor replaces the last visit either. A listing that answers `collection-not-found` calls * `onCollectionMissing()` when given (the shell shows its deleted-collection page); otherwise (a saved view on a - * deleted collection, D140) the page lists nothing and says so. + * deleted collection, D140) the page lists nothing and says so. `collections` (the shell's client store) adds the + * selection bar's Add to collection, and Remove from collection while a collection is open (D138). */ export function mountImages(main, api, parentScope, {embedded = false, fetchPage, emptyText, focusTarget = null, savedState = null, - favorites = false, collection: collectionId = null, onCollectionMissing = null, onSaveView = () => {}, activity = null} = {}) { + favorites = false, collection: collectionId = null, onCollectionMissing = null, onSaveView = () => {}, activity = null, + collections = null} = {}) { const scope = createScope(); const dispose = () => scope.dispose(); parentScope.signal.addEventListener('abort', dispose, {once:true}); @@ -271,7 +273,10 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage getFilter:() => { const filter = listingFilter(filters), scope = state?.items[0]?.root_scope; return filter.root !== undefined && typeof scope === 'string' ? {...filter, root_scope:scope} : filter; - }}); + }, + // #331 D138: Add to collection, and Remove from collection on an open collection, which then lists it again. + collections, collection:filters.collection || null, onCollectionsChanged:collectionChanged, + onSourceChanged:refreshInspectionCollection}); if (bar) main.append(bar.element); main.append(body); const mediaViewer = createMediaViewer({api,container:main,parentScope:scope,inspectionController:inspection,sender, @@ -682,6 +687,19 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage const band = parseFloat(getComputedStyle(optionsStatus).scrollMarginBlockEnd) || 0; if (bottom > innerHeight - band && top < innerHeight) optionsStatus.scrollIntoView({block:'nearest', inline:'nearest'}); } + /** + * A confirmed collection change that may change this listing (a remove from the open collection): the loaded range + * is read again in place, keeping the scroll and the checked files still listed. A failed re-read lists the page + * again from its start, whose failure shows the usual Retry (or the deleted-collection page). + */ + async function collectionChanged() { + if (scope.signal.aborted || !filters.collection || recovering) return; + try { + await stream.refresh(); + } catch { + if (!scope.signal.aborted) reset(); + } + } async function refreshInspectionCollection() { if (scope.signal.aborted) return; // Numeric roots may now name another source; retire the old stream first. diff --git a/web/js/workspace/index.mjs b/web/js/workspace/index.mjs index 3670e764..e3a41f2a 100644 --- a/web/js/workspace/index.mjs +++ b/web/js/workspace/index.mjs @@ -381,7 +381,7 @@ function mountWorkspace() { if (route.id === 'images' || route.id === 'favorites' || route.id === 'collection') { mountNewPromptAction(main, api, view); images = mountImages(main, api, view, {savedState:saved?.state, favorites:route.id === 'favorites', - collection, onCollectionMissing, onSaveView:saveView('images', view), activity}); + collection, onCollectionMissing, onSaveView:saveView('images', view), activity, collections}); return; } if (route.id === 'library') { diff --git a/web/js/workspace/workspace-dialogs.mjs b/web/js/workspace/workspace-dialogs.mjs index 257f3fba..3fd3b0de 100644 --- a/web/js/workspace/workspace-dialogs.mjs +++ b/web/js/workspace/workspace-dialogs.mjs @@ -321,10 +321,11 @@ export function openCopyFallback(text, { scope, trigger = null, title = 'Copy pr * `action`. The action reports the trimmed value and closes; an empty value keeps the dialog open on its field, or * reports `fallback` when one is given (Save view's "Saved view", a1339). A positive `maxLength` limits the field. * `validate(value)` may refuse a value with a message: the dialog then stays open with the typed text and the field - * reports the message (its native validity) until it is edited. + * reports the message (its native validity) until it is edited. `suggestions` (text) adds a `datalist` the field + * lists, as Add to collection offers the existing names (#331 D138); the field still takes any text. */ export function openInputDialog({ scope, title, label, action, value = '', maxLength, fallback, onApply, trigger = null, - validate = null }) { + validate = null, suggestions = [] }) { requireScope(scope); requireCallbacks({ onApply }); if (validate !== null) requireCallbacks({ validate }); @@ -334,6 +335,9 @@ export function openInputDialog({ scope, title, label, action, value = '', maxLe if (fallback !== undefined && (typeof fallback !== 'string' || !fallback.trim())) { throw new TypeError('The input dialog fallback must be text'); } + if (!Array.isArray(suggestions) || suggestions.some(item => typeof item !== 'string')) { + throw new TypeError('The input dialog suggestions must be text'); + } const input = el('input'); input.value = typeof value === 'string' ? value : ''; if (Number.isSafeInteger(maxLength) && maxLength > 0) input.maxLength = maxLength; @@ -356,7 +360,15 @@ export function openInputDialog({ scope, title, label, action, value = '', maxLe }); const actions = el('div', undefined, 'dialog-actions'); actions.append(cancel, apply); - show(title, [field(label, input, 'action-input'), actions], { scope, trigger, focusTarget: input }); + const row = field(label, input, 'action-input'); + if (suggestions.length) { + const list = el('datalist'); + list.id = 'action-input-suggestions'; + list.append(...suggestions.map(text => Object.assign(el('option'), { value: text }))); + input.setAttribute('list', list.id); + row.append(list); + } + show(title, [row, actions], { scope, trigger, focusTarget: input }); } /**