fix(collections): address review findings

- A list read superseded by a newer one (the 300 ms count read, a restore)
  no longer shows "could not be loaded": the collection and saved-view
  pages wait for the list to settle.
- A listing that finds the open collection gone shows the deleted page once
  the list no longer holds it; when the list read fails or still holds it,
  the page keeps the listing's error and Retry instead of staying blank, and
  each failed read is reported again.
- The missing-members note follows every list read and goes away at zero.
- A rename that settles after its Edit dialog closed leaves any dialog
  opened since alone.
- The deleted-collection, list-failed and saved-view failure pages focus
  their title after a route change, as every page does.

Part of #331
This commit is contained in:
Vito Sansevero
2026-10-27 20:21:00 -07:00
parent ed184930da
commit f0022aca29
4 changed files with 211 additions and 19 deletions
@@ -330,3 +330,164 @@ 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);
});
/** A Playwright hold on the collections list read: `held` fills as reads arrive; `release()` lets them all through. */
async function holdListReads(page, answer = route => route.fallback()) {
const held = [];
let open = false;
await page.route(`**${API}`, route => {
if (route.request().method() !== 'GET') return route.fallback();
if (open) return answer(route);
held.push(route);
return undefined;
});
return {
held,
async release() {
open = true;
for (const route of held.splice(0)) await answer(route).catch(() => {});
},
};
}
test('a list read superseded by another read still opens a collection made since (review M1)', async ({ page, request }) => {
const manifest = await seed(request);
const listing = await (await request.get('/prompt_manager/images/output?mode=workspace&limit=1')).json();
const [file] = listing.images;
await page.goto(`${workspace}#/collection/${manifest.collections.Landscapes.id}`);
await expect(heading(page)).toHaveText('Landscapes');
// Another tab makes "Fresh"; this tab does not list it yet.
const added = await request.post(`${API}/items`, { data: { name: 'Fresh',
items: [{ root_index: file.root_index, root_scope: file.root_scope, path: file.path }] } });
await expect(added).toBeOK();
const fresh = (await added.json()).collection.id;
await expect(rows(page)).toHaveCount(2);
const hold = await holdListReads(page);
// A rename schedules the 300 ms count re-read; opening "Fresh" first reads the list once more. The timer's read
// supersedes that one while both are held.
await editButton(page).click();
await nameField(page).fill('Landscape grids');
await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(toast(page)).toHaveText('Collection renamed.');
await page.evaluate(hash => { window.location.hash = hash; }, `#/collection/${fresh}`);
await expect.poll(() => hold.held.length).toBeGreaterThanOrEqual(2);
await expect(main(page)).not.toContainText('Collections could not be loaded.');
await hold.release();
await expect(heading(page)).toHaveText('Fresh');
await expect(imageCards(page)).toHaveCount(1);
});
test('a listing that finds the collection gone while the list still has it offers Retry (review M2)', async ({ page, request }) => {
const manifest = await seed(request);
const { id } = manifest.collections.Experiments;
let gone = true;
await page.route('**/prompt_manager/images/output?*', route => gone && route.request().url().includes(`collection=${id}`)
? route.fulfill({ status: 404, contentType: 'application/json',
body: '{"success":false,"error":"Collection not found","code":"collection-not-found"}' })
: route.fallback());
await page.goto(`${workspace}#/images`);
await expect(rows(page)).toHaveCount(2);
// The shell's re-read is held, then fails: the page must not stay blank.
const hold = await holdListReads(page, route => route.fulfill({ status: 500, contentType: 'application/json',
body: '{"success":false,"error":"Collections could not be read."}' }));
await row(page, 'Experiments').click();
await expect.poll(() => hold.held.length).toBe(1);
await hold.release();
const retry = main(page).getByRole('button', { name: 'Retry', exact: true });
await expect(retry).toBeVisible();
await expect(main(page).locator('#gallery-continuation p')).toHaveText('Images could not be loaded. Retry or rescan the collection.');
gone = false;
await page.unroute(`**${API}`);
await retry.click();
await expect(imageCards(page)).toHaveCount(8);
// The list read again still lists it: the same Retry, never a blank page.
gone = true;
await page.evaluate(() => { window.location.hash = '#/images'; });
await expect(heading(page)).toHaveText('Images');
await row(page, 'Experiments').click();
await expect(retry).toBeVisible();
gone = false;
await retry.click();
await expect(imageCards(page)).toHaveCount(8);
});
test('the missing-members note follows the list read again (review L2)', async ({ page, request }) => {
const manifest = await seed(request);
const { id } = manifest.collections.Landscapes;
let missing = null;
await page.route(`**${API}`, async route => {
if (route.request().method() !== 'GET' || missing === null) return route.fallback();
const response = await route.fetch();
const data = await response.json();
data.collections = data.collections.map(item => item.id === id ? { ...item, missing } : item);
return route.fulfill({ response, json: data });
});
await page.goto(`${workspace}#/collection/${id}`);
await expect(missingNote(page)).toHaveText('1 image in this collection is not in your current sources.');
const rename = async name => {
await editButton(page).click();
await nameField(page).fill(name);
await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(toast(page)).toHaveText('Collection renamed.');
};
missing = 3;
await rename('Landscapes A');
await expect(missingNote(page)).toHaveText('3 images in this collection are not in your current sources.');
missing = 0;
await rename('Landscapes B');
await expect(missingNote(page)).toHaveCount(0);
missing = 1;
await rename('Landscapes C');
await expect(missingNote(page)).toHaveText('1 image in this collection is not in your current sources.');
});
test('a rename that settles after its dialog closed leaves the next dialog open (review L1)', 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);
let release;
const released = new Promise(resolve => { release = resolve; });
await page.route(`**${API}/${id}`, async route => {
if (route.request().method() !== 'PATCH') return route.fallback();
await released;
return route.fallback();
});
await editButton(page).click();
await nameField(page).fill('Later');
await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await page.keyboard.press('Escape');
await expect(editDialog(page)).toHaveCount(0);
await main(page).getByRole('button', { name: 'Save view', exact: true }).click();
const save = page.getByRole('dialog', { name: 'Save this view', exact: true });
await expect(save).toBeVisible();
release();
await expect(heading(page)).toHaveText('Later');
await expect(save).toBeVisible();
});
test('a page that says a collection or view could not be opened takes focus after a route change (review L3)', async ({ page, request }) => {
await seed(request);
await page.goto(`${workspace}#/images`);
await expect(heading(page)).toHaveText('Images');
await page.evaluate(() => { window.location.hash = '#/collection/999999'; });
await expect(description(page)).toHaveText('This collection no longer exists.');
await expect(heading(page)).toBeFocused();
await page.route(`**${API}`, route => route.request().method() === 'GET'
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"x"}' })
: route.fallback());
await page.evaluate(() => { window.location.hash = '#/images'; });
await expect(heading(page)).toHaveText('Images');
await page.evaluate(() => { window.location.hash = '#/collection/999998'; });
await expect(main(page).getByRole('button', { name: 'Retry', exact: true })).toBeVisible();
await expect(heading(page)).toBeFocused();
await page.route('**/prompt_manager/saved-views', route => route.request().method() === 'GET'
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"x"}' })
: route.fallback());
await page.evaluate(() => { window.location.hash = '#/saved/5'; });
await expect(description(page)).toHaveText('Saved views could not be loaded. Your saved data has not changed.');
await expect(heading(page)).toBeFocused();
});
+12 -6
View File
@@ -466,9 +466,13 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
if (!resizeFrame) resizeFrame = requestAnimationFrame(() => { resizeFrame = 0; updateLayout(); });
});
scope.cleanup(() => { resize.disconnect(); observer?.disconnect(); cancelAnimationFrame(resizeFrame); cancelAnimationFrame(frame); cancelAnimationFrame(loadFrame); });
/** The listing refused the open collection: it was deleted (MC6, D140). */
const collectionGone = next => Boolean(filters.collection) && isCollectionGone(next.error);
let reportedMissing = false;
/**
* The listing refused the open collection: it was deleted. A saved view says so in place (MC6, D140); a collection
* page reports it (`onCollectionMissing`) once per failed read and meanwhile shows the listing's error and Retry,
* so a page whose shell cannot confirm the deletion is never left blank.
*/
const collectionGone = next => Boolean(filters.collection) && !onCollectionMissing && isCollectionGone(next.error);
let reportedMissing = null;
function continuationText(next) {
if (recovering) return 'Images paused until sources and folders are available.';
if (collectionGone(next)) return '';
@@ -511,9 +515,11 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
retry.hidden = !next.error || gone;
more.hidden = !next.hasMore || !!next.error;
more.setAttribute('aria-disabled', String(next.loading || recovering));
if (gone && onCollectionMissing) {
if (!reportedMissing) { reportedMissing = true; queueMicrotask(() => { if (!scope.signal.aborted) onCollectionMissing(); }); }
} else if (showsEmpty) {
if (onCollectionMissing && filters.collection && isCollectionGone(next.error) && reportedMissing !== next.error) {
reportedMissing = next.error;
queueMicrotask(() => { if (!scope.signal.aborted) onCollectionMissing(); });
}
if (showsEmpty) {
setText(empty, gone ? VIEW_COLLECTION_DELETED : embedded ? emptyText : NO_MATCHES);
empty.hidden = !gone && (recovering || next.loading || !!next.error || next.hasMore || next.loaded > 0);
}
+34 -12
View File
@@ -250,7 +250,9 @@ function mountWorkspace() {
if (view.signal.aborted) return;
// A view saved since the list loaded (another tab) is read once more before the link counts as gone.
if (!savedViews.find(route.savedId) && savedViews.status === 'ready') {
await savedViews.load();
// settled(), not load()'s own promise: a newer read (a restore, another change) can supersede this one.
savedViews.load();
await savedViews.settled();
if (view.signal.aborted) return;
}
const saved = savedViews.find(route.savedId);
@@ -265,7 +267,8 @@ function mountWorkspace() {
window.location.replace('#/images');
return;
}
const {description} = renderHeading(main, {title:route.label});
const {description, h1} = renderHeading(main, {title:route.label});
focusTitle(h1);
description.textContent = 'Saved views could not be loaded. Your saved data has not changed.';
description.setAttribute('role', 'alert');
const retry = createButton('Retry');
@@ -319,7 +322,9 @@ function mountWorkspace() {
await collections.settled();
if (view.signal.aborted) return;
if (!collections.find(route.collectionId) && collections.status === 'ready') {
await collections.load();
// settled(), not load()'s own promise: the 300 ms count read or a restore can supersede this one.
collections.load();
await collections.settled();
if (view.signal.aborted) return;
}
let collection = collections.find(route.collectionId);
@@ -327,12 +332,23 @@ function mountWorkspace() {
if (collection) {
renderPage({...COLLECTION_ROUTE, label:collection.name, title:routeTitle(COLLECTION_ROUTE)}, view, null, {collection:collection.id,
// The listing found the collection gone meanwhile: the list is read again and, once it no longer lists
// the collection, the page is shown again (the deleted-collection page).
onCollectionMissing:() => collections.load().then(() => {
if (!collections.find(route.collectionId)) showAgain();
})});
const note = missingNote(collection.missing);
if (note) pageHeading(main).description.after(element('p', note, 'small'));
// the collection, the page is shown again (the deleted-collection page). A failed read, or a list that
// still holds it, leaves the listing's own error and Retry (images.mjs).
onCollectionMissing:async () => {
collections.load();
await collections.settled();
if (collections.status === 'ready' && !collections.find(route.collectionId)) showAgain();
}});
// MC3: the note follows every read of the list for the page's life; none while nothing is missing.
const note = element('p', '', 'small');
const showNote = () => {
const text = missingNote(collections.find(route.collectionId)?.missing);
note.textContent = text;
if (!text) note.remove();
else if (!note.isConnected) pageHeading(main)?.description.after(note);
};
showNote();
view.cleanup(collections.subscribe(showNote));
// The dialog's Delete question uses the latest stored count.
mountEditAction(view, trigger => openEditCollectionDialog({ scope: view, trigger,
collection: collection = {...collection, ...collections.find(collection.id), name:collection.name},
@@ -349,7 +365,8 @@ function mountWorkspace() {
onGone: () => collections.settled().then(showAgain) }));
return;
}
const {description} = renderHeading(main, {title:route.label});
const {description, h1} = renderHeading(main, {title:route.label});
focusTitle(h1);
document.title = `${routeTitle(route)} · Prompt Manager`;
document.getElementById('workspace-breadcrumb').textContent = routeTitle(route);
if (collections.status === 'ready') {
@@ -370,13 +387,18 @@ function mountWorkspace() {
main.append(retry);
}
/** The concept focuses its page title on a hashchange navigation (app.js 1168), never on the first render. */
function focusTitle(h1) {
if (!firstRender) h1.focus();
firstRender = false;
}
function renderPage(route, view, saved = null, {collection = null, onCollectionMissing = null} = {}) {
document.title = `${routeTitle(route)} · Prompt Manager`;
// The concept's heading(); each view fills its description and actions.
// The concept focuses its page title on a hashchange navigation (app.js 1168); the skip link focuses `main`.
const { description, h1 } = renderHeading(main, { title: route.label });
if (!firstRender) h1.focus();
firstRender = false;
focusTitle(h1);
document.getElementById('workspace-breadcrumb').textContent = routeTitle(route);
if (route.id === 'images' || route.id === 'favorites' || route.id === 'collection') {
mountNewPromptAction(main, api, view);
+4 -1
View File
@@ -265,15 +265,18 @@ export function openEditDialog({ scope, trigger, item, nouns, onRename, onDelete
// Kept open: one rename at a time, its outcome shown in place.
say('');
for (const button of [remove, cancel, save]) button.disabled = true;
// Closed meanwhile (Escape, Close): the outcome is only toasted, and a dialog opened since is left alone.
const stillOpen = () => input.isConnected;
try {
await onRename(name);
if (scope.signal.aborted) return;
closeWorkspaceDialog();
if (stillOpen()) closeWorkspaceDialog();
showWorkspaceToast(nouns.renamed);
} catch (error) {
if (scope.signal.aborted) return;
for (const button of [remove, cancel, save]) button.disabled = false;
const outcome = renameError(error) ?? { toast: nouns.renameFailed };
if (!stillOpen()) { showWorkspaceToast(outcome.field ?? outcome.toast); return; }
if (outcome.field) { say(outcome.field); input.focus(); return; }
if (outcome.close) closeWorkspaceDialog();
showWorkspaceToast(outcome.toast);