fix(collections): address review findings
- M1: after a failed Save the Collections dialog shows the membership loading and keeps Save disabled until the re-read lands, so a stale plan cannot be sent. - M2: the changes a Save made reach the page only when the dialog closes, so a collection page's reload cannot remove the dialog before its failure is read. - L4: a New collection name equal to a listed collection ignoring case is that collection's checkbox, checked; never an add then a remove. - L5: a name that cannot be a collection name is refused on the field; nothing is sent, the checkbox edits stay, no re-read. - L6: focus starts on the field and moves to the first checkbox once listed; closing after a re-render replaced the trigger focuses the inspector's current Collections… button or its heading (openContentDialog takes an optional focusTarget). - L3, L7: TESTING.md D138/D139 describe the stale-source and deleted collection outcomes and the above; the viewer spec asserts no listing request while it is open, and a browser test covers M2.
This commit is contained in:
+27
-15
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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(); }
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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. ' +
|
||||
|
||||
Reference in New Issue
Block a user