diff --git a/TESTING.md b/TESTING.md index c5c6ea5e..2c845b9e 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1967,14 +1967,17 @@ Recorded deviations: “X”." plus " N was/were already in it." and " N could not be found.", "All selected images were already in “X”.", "None of the selected images could be found. Refresh the images and try again." (404 `files-not-found`), "The sources changed. Refresh - the images and try again." (409, then the listing reloads), "That collection no - longer exists." and "The images could not be added. Try again.". The selection stays - and the listing is not reloaded. **Remove from collection** (beyond the concept) - follows Add while a collection filter is active (a collection page or a saved view - on one), with no confirmation: "Removed N image(s) from “X”.", then the loaded range - is read again in place, keeping the checked files still listed; "The images could - not be removed. Try again." keeps everything. A second press while it runs is - ignored. + the images and try again." (409; the sources and the listing are read again, which + clears the selection), "That collection no longer exists." and "The images could not + be added. Try again.". Otherwise the selection stays and the listing is not reloaded. + **Remove from collection** (beyond the concept) follows Add while a collection filter + is active (a collection page or a saved view on one), with no confirmation: "Removed + N image(s) from “X”.", then the loaded range is read again in place, keeping the + checked files still listed. "That collection no longer exists." (404, a collection + deleted meanwhile) reads the listing again, which then shows the deleted-collection + page; "The sources changed. Refresh the images and try again." (409) reads the + sources and listing again and clears the selection; "The images could not be removed. + Try again." keeps everything. A second press while it runs is ignored. - **D139** (beyond the concept): the shared inspector's footer ends with **Collections…** (the folder icon) after Prompt history, for a listed file only (`rooted-file` with its scope), in the Images side panel and the viewer's details; @@ -1984,13 +1987,22 @@ Recorded deviations: concept checkbox row (`label.tag-suggestion`) per collection in sidebar order, checked where the file is a member ("No collections yet. Name one below." without any), a "New collection" field (at most 100 characters; a name matching an existing - collection adds to it), then Cancel and Save. Save sends one request per change: 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; × and Escape wait while it saves. On a collection page a - change to the open collection reloads the listing at once from the side panel, and - from the viewer only when the viewer closes, so the viewer keeps its place. The + collection adds to it), then Cancel and Save. Focus starts on the field and moves to + the first checkbox once they are listed. A typed name equal to a listed collection + ignoring case is that collection's checkbox, checked (never an add then a remove); a + name that cannot be a collection name is refused on the field with "The collections + could not be updated. Try again.", nothing is sent and the checkbox edits stay. Save + sends one request per change: 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, Save waiting until that read + lands; × and Escape wait while it saves. The page hears of the changes that went + through only when the dialog closes (a failure included), so a reload cannot remove + the dialog before its message is read. On a collection page a change to the open + collection then reloads the listing at once from the side panel, and from the viewer + only when the viewer closes, so the viewer keeps its place (no listing request while + it is open). Closing returns focus to the trigger, or, when a re-render replaced it, + to the inspector's current Collections… button or its heading. The inspector comparison draws the button into the concept's panel footer and full details after Prompt history (a copy of its Prompt history button with the concept's folder icon), so the browser lays out what follows. diff --git a/tests/browser/workspace-collections.spec.mjs b/tests/browser/workspace-collections.spec.mjs index 7429ad90..a77564ed 100644 --- a/tests/browser/workspace-collections.spec.mjs +++ b/tests/browser/workspace-collections.spec.mjs @@ -744,8 +744,14 @@ test('Collections… in the viewer keeps the viewer until it closes (CUJ 7)', as 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'); + // Review L7: no listing request while the viewer is open. + let listings = 0; + const countListing = request => { if (new URL(request.url()).pathname === '/prompt_manager/images/output') listings++; }; + page.on('request', countListing); await viewer.locator('#viewer-details').getByRole('button', { name: 'Collections…', exact: true }).click(); await expect(choice(page, 'Landscapes')).toBeChecked(); + // Review L6: focus moves to the first checkbox once listed. + await expect(choice(page, 'Experiments')).toBeFocused(); await choice(page, 'Landscapes').uncheck(); await collectionsDialog(page).getByRole('button', { name: 'Save', exact: true }).click(); await expect(toast(page)).toHaveText('Collections updated.'); @@ -754,6 +760,8 @@ test('Collections… in the viewer keeps the viewer until it closes (CUJ 7)', as 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); + expect(listings, 'no listing request while the viewer is open').toBe(0); + page.off('request', countListing); await viewer.getByRole('button', { name: 'Close image viewer', exact: true }).click(); await expect(viewer).toHaveCount(0); await expect(imageCards(page)).toHaveCount(11); @@ -812,3 +820,37 @@ for (const theme of ['dark', 'light']) { expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); }); } + +// Review M2: on a collection page, a Save that removed the file from the open collection and then failed keeps its +// dialog and message; the listing is read again only once the dialog closes. +test('a partly failed Save from the side panel keeps its dialog until it closes', async ({ page, request }) => { + const manifest = await seed(request); + const { Experiments, Landscapes } = manifest.collections; + // experiment_01 is in both collections; Experiments sorts first, so its remove is sent before Landscapes'. + const [image] = (await (await request.get('/prompt_manager/images/output?mode=workspace&q=experiment_01')).json()).images; + const item = { root_index: image.root_index, root_scope: image.root_scope, path: image.path }; + await expect(await request.post(`${API}/items`, { data: { collection_id: Landscapes.id, items: [item] } })).toBeOK(); + await page.route(`**${API}/items`, route => { + const body = route.request().postDataJSON(); + return route.request().method() === 'DELETE' && body.collection_id === Landscapes.id + ? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"x"}' }) + : route.fallback(); + }); + await page.goto(`${workspace}#/collection/${Experiments.id}`); + await expect(imageCards(page)).toHaveCount(8); + await expect(panel(page).getByRole('heading', { level: 2 })).toHaveText('experiment_01.png'); + await panel(page).getByRole('button', { name: 'Collections…', exact: true }).click(); + await expect(choice(page, 'Landscapes')).toBeChecked(); + await choice(page, 'Experiments').uncheck(); + await choice(page, 'Landscapes').uncheck(); + 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.'); + // The remove from Experiments went through; the dialog shows it and the page has not reloaded under it. + await expect(choice(page, 'Experiments')).not.toBeChecked(); + await expect(choice(page, 'Landscapes')).toBeChecked(); + await expect(imageCards(page)).toHaveCount(8); + await collectionsDialog(page).getByRole('button', { name: 'Cancel', exact: true }).click(); + await expect(collectionsDialog(page)).toHaveCount(0); + await expect(imageCards(page)).toHaveCount(7); + await expect(card(page, 'collections/experiment_01.png')).toHaveCount(0); +}); diff --git a/tests/js/workspace-file-inspector.test.js b/tests/js/workspace-file-inspector.test.js index f0571719..b89f2c43 100644 --- a/tests/js/workspace-file-inspector.test.js +++ b/tests/js/workspace-file-inspector.test.js @@ -423,7 +423,8 @@ function openCollections(store, extra = {}) { 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}); + // As the shell does: closing runs the dialog's onClose once. + closeDialog:() => { closed++; dialog?.onClose?.(); }, ...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'); @@ -536,23 +537,108 @@ test('a failed Save keeps the dialog, says so and shows the current membership', 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:[]}]); + // The add went through; the list shows what the server holds now. Review M2: the page hears about it only once + // the dialog closes, so a listing refresh cannot dispose the dialog before its failure is read. + assert.deepEqual(view.changes, []); 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); + view.button('Cancel').fire('click'); + assert.deepEqual(view.changes, [{added:[5], removed:[]}]); } finally { view.done(); } }); -test('a name the store cannot take fails without a request', async () => { +test('a name the store cannot take is refused on the field, keeping the edits (review L5)', 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'; + const [lora, portraits] = view.boxes(); + lora.checked = false; portraits.checked = true; + const field = view.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0]; + const validity = []; + field.setCustomValidity = text => validity.push(text); + field.reportValidity = () => validity.push('reported'); + field.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.'); + assert.deepEqual(store.calls.map(call => call[0]), ['membership'], 'nothing sent and no re-read'); + assert.deepEqual(validity, ['The collections could not be updated. Try again.', 'reported']); + assert.deepEqual(view.boxes().map(box => box.checked), [false, true], 'the checkbox edits stay'); + assert.equal(view.closed, 0); + assert.equal(view.button('Save').getAttribute('aria-disabled'), null); + // Editing the field clears its message. + field.fire('input'); + assert.equal(validity.at(-1), ''); } finally { view.done(); } }); + + +test('after a failed Save, Save waits for the membership re-read (review M1)', async () => { + let release; + const store = collectionsStore({remove:() => { throw new Error('x'); }}); + store.settled = () => new Promise(resolve => { release = resolve; }); + const view = openCollections(store); + try { + await view.settle(); + view.boxes()[0].checked = false; + view.button('Save').fire('click'); + for (let index = 0; index < 3; index++) await view.settle(); + assert.equal(typeof release, 'function', 'the store is settling'); + assert.equal(view.button('Save').getAttribute('aria-disabled'), 'true'); + assert.ok(view.text().includes('Loading collections…')); + const sent = store.calls.length; + view.button('Save').fire('click'); + await view.settle(); + assert.equal(store.calls.length, sent, 'no stale plan is sent'); + release({}); + for (let index = 0; index < 3; index++) await view.settle(); + assert.equal(view.button('Save').getAttribute('aria-disabled'), null); + } finally { view.done(); } +}); + +test('a typed name of an existing collection is that collection checkbox (review L4)', async () => { + // "portraits" names Portraits (not a member): one add by id, no name request. + let store = collectionsStore(); + let view = openCollections(store); + try { + await view.settle(); + view.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0].value = ' PORTRAITS '; + view.button('Save').fire('click'); + for (let index = 0; index < 3; index++) await view.settle(); + assert.deepEqual(store.calls.slice(1), [['add', {collectionId:5}, [FILE]]]); + } finally { view.done(); } + // Unchecking LoRa testing while typing its name keeps it: nothing to send. + store = collectionsStore(); + view = openCollections(store); + try { + await view.settle(); + view.boxes()[0].checked = false; + view.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0].value = 'lora testing'; + view.button('Save').fire('click'); + await view.settle(); + assert.deepEqual(store.calls.map(call => call[0]), ['membership']); + assert.equal(view.closed, 1); + } finally { view.done(); } +}); + +test('focus starts on the field, moves to the first checkbox once listed, and falls back on close (review L6)', async () => { + const trigger = {isConnected:false, focus() { assert.fail('a detached trigger is not focused'); }}; + const fallback = {focused:false, focus() { this.focused = true; }}; + const view = openCollections(collectionsStore(), {trigger, focusFallback:() => fallback}); + try { + const field = view.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0]; + assert.equal(view.dialog.focusTarget, field); + await view.settle(); + assert.equal(view.boxes()[0].focused, true); + view.button('Cancel').fire('click'); + assert.equal(fallback.focused, true); + } finally { view.done(); } + const empty = collectionsStore({member:[]}); + empty.collections = []; + const none = openCollections(empty); + try { + await none.settle(); + assert.equal(none.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0].focused, true); + } finally { none.done(); } +}); diff --git a/web/js/workspace/collections.mjs b/web/js/workspace/collections.mjs index 827b3c16..6491536a 100644 --- a/web/js/workspace/collections.mjs +++ b/web/js/workspace/collections.mjs @@ -78,7 +78,8 @@ export function parseCollections(data) { } /** The sidebar's order while a re-read is pending: an approximation of the server's name key, then id. */ -const nameKey = name => name.normalize('NFC').toLowerCase().normalize('NFC'); +export const collectionNameKey = name => name.normalize('NFC').toLowerCase().normalize('NFC'); +const nameKey = collectionNameKey; const ordered = list => [...list].sort((a, b) => { const left = nameKey(a.name), right = nameKey(b.name); return left < right ? -1 : left > right ? 1 : a.id - b.id; diff --git a/web/js/workspace/file-inspector.mjs b/web/js/workspace/file-inspector.mjs index 064b6723..9c38f765 100644 --- a/web/js/workspace/file-inspector.mjs +++ b/web/js/workspace/file-inspector.mjs @@ -309,9 +309,12 @@ export function mountFileInspector(container, {api, sender, parentScope, variant const member = collections ? revealTarget(record.identity) : null; if (member) { const button = createButton(''); + button.dataset.collections = ''; button.append(createWorkspaceIcon('folder'), ' Collections…'); + // A re-render replaces the button: closing focuses the current one, else the inspector's heading. + const fallback = () => container.querySelector('button[data-collections]') ?? container.querySelector('h2'); contentScope.listen(button, 'click', () => openCollectionsDialog({scope, collections, target:member, trigger:button, - onChanged:onCollectionsChanged})); + onChanged:onCollectionsChanged, focusFallback:fallback})); footer.append(button); } if (!full && footer.childNodes.length) container.append(footer); diff --git a/web/js/workspace/inspector-collections.mjs b/web/js/workspace/inspector-collections.mjs index c2a3cbcd..87c8598f 100644 --- a/web/js/workspace/inspector-collections.mjs +++ b/web/js/workspace/inspector-collections.mjs @@ -9,13 +9,19 @@ * - 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. + * went through still reach `onChanged({added, removed})` (collection ids), as a full success does, but only once the + * dialog closes: a page that lists the file again (a collection page) would otherwise remove the dialog before its + * failure is read. Save waits for the membership read again. + * - A New collection name equal to a listed collection ignoring case is that collection's checkbox, checked. A name + * that cannot be a collection name is refused on the field; nothing is sent and the checkbox edits stay. + * - Focus starts on the field and moves to the first checkbox once they are listed; closing returns it to the + * trigger, or to `focusFallback()` when an inspector re-render removed the trigger. */ 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'; + NO_COLLECTIONS_TEXT, UPDATE_FAILED_TEXT, collectionNameKey, normalizeCollectionName } from './collections.mjs'; export const COLLECTIONS_DIALOG_TITLE = 'Collections'; export const NEW_COLLECTION_LABEL = 'New collection'; @@ -32,10 +38,12 @@ function el(tag, text, className) { * and `closeDialog` are the dialog seams; `notify` the toast. */ export function openCollectionsDialog({ scope, collections, target, trigger = null, notify = showWorkspaceToast, onChanged = null, - openDialog = openContentDialog, closeDialog = closeWorkspaceDialog }) { + focusFallback = null, openDialog = openContentDialog, closeDialog = closeWorkspaceDialog }) { const local = new AbortController(); const active = () => !scope.signal.aborted && !local.signal.aborted; - let member = null, saving = false; + let member = null, saving = false, listed = false; + // The confirmed changes, reported once when the dialog closes (review M2). + const changes = { added: [], removed: [] }; const status = el('p', '', 'small'); const retry = createButton('Retry'); const list = el('div'); @@ -45,6 +53,7 @@ export function openCollectionsDialog({ scope, collections, target, trigger = nu input.value = ''; input.maxLength = MAX_COLLECTION_NAME_LENGTH; input.autocomplete = 'off'; + input.addEventListener('input', () => input.setCustomValidity?.('')); const problem = el('p', '', 'small'); problem.setAttribute('role', 'alert'); problem.hidden = true; @@ -84,53 +93,67 @@ export function openCollectionsDialog({ scope, collections, target, trigger = nu status.hidden = !text; retry.hidden = !failed; } - async function load() { + /** Save waits while the membership is read (again). */ + function loading() { member = null; paintSave(); list.replaceChildren(); showStatus(MEMBERSHIP_LOADING_TEXT, false); + } + async function load() { + loading(); try { const ids = await collections.membership(target); if (!active()) return; member = new Set(ids); showStatus('', false); renderList(); + // The first listing takes focus from the untouched field to the first checkbox (review L6). + if (!listed && !input.value) firstBox().focus(); + listed = true; } 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. */ + const boxesOf = () => [...list.children].map(row => row.children?.[0]).filter(box => box?.dataset?.collectionId); + const firstBox = () => boxesOf()[0] ?? input; + /** + * The requests Save sends, in order; null when the new name cannot be a collection name. A name equal to a listed + * collection's ignoring case is that collection's checkbox, checked (review L4). + */ 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 }); - } + const named = name ? collections.collections.find(item => collectionNameKey(item.name) === collectionNameKey(name)) : null; + const steps = name && !named ? [{ add: { name } }] : []; + const boxes = boxesOf().map(box => ({ id: Number(box.dataset.collectionId), checked: box.checked })) + .map(box => (box.id === named?.id ? { ...box, checked: true } : box)); + for (const box of boxes) if (box.checked && !member.has(box.id)) steps.push({ add: { collectionId: box.id } }); + for (const box of boxes) if (!box.checked && member.has(box.id)) steps.push({ remove: box.id }); return steps; } async function submit() { problem.textContent = ''; problem.hidden = true; const steps = plan(); - if (steps?.length === 0) { closeDialog(); return; } + if (steps === null) { + // Review L5: refused on the field; nothing is sent and the checkbox edits stay. + input.setCustomValidity?.(UPDATE_FAILED_TEXT); + input.reportValidity?.(); + input.focus(); + return; + } + if (steps.length === 0) { closeDialog(); return; } saving = true; paintSave(); - const added = [], removed = []; - let failed = steps === null; - for (const step of steps ?? []) { + let failed = false; + 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); + if (step.add) changes.added.push((await collections.add(step.add, [target])).collection.id); + else changes.removed.push((await collections.remove(step.remove, [target])).collection.id); } catch { failed = true; break; @@ -140,7 +163,6 @@ export function openCollectionsDialog({ scope, collections, target, trigger = nu } if (!active()) return; saving = false; - if (added.length || removed.length) onChanged?.({ added, removed }); if (!failed) { closeDialog(); notify(COLLECTIONS_UPDATED_TOAST); @@ -148,12 +170,20 @@ export function openCollectionsDialog({ scope, collections, target, trigger = nu } 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. + // Review M1: Save waits from here until the membership is read again. A collection deleted meanwhile is + // re-read by the store; the list then shows what the server holds. + loading(); await collections.settled(); if (active()) await load(); } + function closed() { + local.abort(); + if (changes.added.length || changes.removed.length) onChanged?.({ added: [...changes.added], removed: [...changes.removed] }); + // The shell focuses a trigger still on the page; one an inspector re-render replaced falls back (review L6). + if (trigger && !trigger.isConnected) focusFallback?.()?.focus(); + } showStatus('', false); - openDialog({ scope, title: COLLECTIONS_DIALOG_TITLE, trigger, canClose: () => !saving, onClose: () => local.abort(), + openDialog({ scope, title: COLLECTIONS_DIALOG_TITLE, trigger, canClose: () => !saving, onClose: closed, focusTarget: input, nodes: [status, retry, list, field(NEW_COLLECTION_LABEL, input, 'collection-new'), problem, actions] }); load(); } diff --git a/web/js/workspace/workspace-dialogs.mjs b/web/js/workspace/workspace-dialogs.mjs index 3fd3b0de..513c2c49 100644 --- a/web/js/workspace/workspace-dialogs.mjs +++ b/web/js/workspace/workspace-dialogs.mjs @@ -376,13 +376,13 @@ export function openInputDialog({ scope, title, label, action, value = '', maxLe * another dialog replaces its body, so the page can dispose what it mounted there. While `canClose()` answers false, * × and Escape leave the dialog open (the page, a route change or a replacing dialog still close it). */ -export function openContentDialog({ scope, title, nodes, onClose = null, trigger = null, canClose = null }) { +export function openContentDialog({ scope, title, nodes, onClose = null, trigger = null, canClose = null, focusTarget = null }) { requireScope(scope); if (typeof title !== 'string' || !title) throw new TypeError('The dialog needs a title'); if (!Array.isArray(nodes) || nodes.some(node => !node || typeof node !== 'object')) throw new TypeError('The dialog needs its nodes'); if (onClose !== null && typeof onClose !== 'function') throw new TypeError('The dialog onClose must be a function'); if (canClose !== null && typeof canClose !== 'function') throw new TypeError('The dialog canClose must be a function'); - show(title, nodes, { scope, trigger, onClose, canClose }); + show(title, nodes, { scope, trigger, onClose, canClose, focusTarget }); } export const SCAN_REVIEW_SENTENCE = 'Enrich positive / negative associations, reuse canonical text and report unchanged files. ' +