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:
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user