fix(images): list sources and folders that load after Refine opened (#272)

* fix(images): list sources and folders that load after Refine opened

"Refine this view" copied the discovered sources and folders when it
opened, so opening Filters before /gallery/subfolders answered left
Source and Folder at "All sources" / "All output folders" until the
dialog was reopened. On a slow CI runner this stalled the touch
"collection viewer follows filtered loaded order beyond mounted cards"
workflow in selectOption('0') until the 30s timeout.

openRefineDialog now returns setOptions(key, options); the Images page
calls it when discovery settles, and the open dialog refills Source and
its Folder list, showing the value it opened with (or the one chosen
since) once offered. It does nothing after the dialog closed or was
replaced.

* test(library): read filtered shelf titles after the cards' frame

The Library concept comparison read card titles as soon as the caption
changed and the card count dropped below six. Production empties the
shelf when a filter applies, writes the new caption when the page
answers and places the cards in the next animation frame, so on a slow
CI runner the empty shelf satisfied "fewer than six" and the titles
read as []. Settle one frame pair before reading, as the Images and
Inspector comparisons already do.
This commit is contained in:
Vito
2026-10-21 12:36:38 -07:00
committed by GitHub
parent 27b4895795
commit 454ac12e1d
5 changed files with 107 additions and 6 deletions
+4
View File
@@ -158,6 +158,10 @@ async function filteredOf(page,apply,undo) {
await apply();
await expect.poll(()=>captionOf(page)).not.toBe(before);
await expect.poll(()=>page.locator(CARDS).count()).toBeLessThan(CONCEPT_PROMPTS);
// Production empties the shelf when the filter applies, writes the answer's caption at once and places its cards
// in the next animation frame (one paint for a viewer), so the empty shelf already counts as "fewer": read the
// titles only after that frame, as the Images and Inspector comparisons do.
await settle(page);
const result={caption:await captionOf(page),titles:await titlesOf(page)};
await undo();
await expect.poll(()=>captionOf(page)).toBe(before);
+24
View File
@@ -1487,6 +1487,30 @@ test('a star press leaves the continuation status, count and caption date untouc
expect(await page.evaluate(() => window.__statusMutations)).toEqual([]);
});
// Opened before the source and folder discovery answered (a slow server), Refine lists them once it does.
test('Refine opened before discovery settles lists the discovered sources and folders', async ({ page }) => {
let release, started;
const held = new Promise(resolve => { release = resolve; }), hit = new Promise(resolve => { started = resolve; });
await page.route('**/prompt_manager/gallery/subfolders?*', async route => { started(); await held; await route.continue(); }, { times: 1 });
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
await hit;
await filtersButton(page).click();
const source = refineDialog(page).getByLabel('Source', { exact: true });
const folder = refineDialog(page).getByLabel('Folder', { exact: true });
const values = select => select.locator('option').evaluateAll(options => options.map(option => option.value));
expect(await values(source)).toEqual(['all']);
expect(await values(folder)).toEqual(['']);
release();
await expect.poll(() => values(source)).toEqual(['all', '0', '1']);
expect(await values(folder)).toEqual(['', 'shared']);
await expect(source).toHaveValue('all');
await source.selectOption('1');
await refineDialog(page).getByRole('button', { name: 'Apply filters', exact: true }).click();
await expect(cards(page)).toHaveCount(1);
await expect(cards(page)).toHaveAttribute('data-image-key', '1:shared/same.png');
});
// #249 final review m1: as in the old field row, a Source offers only its own folders.
test('Refine offers only the chosen source folders', async ({ page, request }) => {
await expect(await request.post('/__test__/add-nested-image')).toBeOK();
+50
View File
@@ -342,6 +342,56 @@ test('refine dialog round-trips values and clear resets them', async () => {
}
});
test('Refine lists options discovered after it opened and keeps the wanted values', async () => {
const { openRefineDialog, openImportDialog, closeWorkspaceDialog } = await dialogsModule;
const dom = fakeDOM();
const scope = (await coreModule).createScope();
try {
let bySource = {};
const optionsFor = source => [['', 'All output folders'], ...(bySource[source] ?? []).map(path => [path, path])];
const fields = [{ key: 'source', options: [['all', 'All sources']] }, { key: 'folder', options: optionsFor('all'), optionsFor },
REFINE_FIELDS[2]];
const applied = [];
const refine = openRefineDialog({ scope, fields, values: { source: '1', folder: 'temp-only', media: 'video' },
onApply: value => applied.push(value), onClear() {} });
const [source, folder, media] = byId(dom, 'modal-body').children.filter(child => child.className === 'field')
.map(field => field.children[1]);
// Opened before discovery: only the cleared values.
assert.deepEqual(optionPairs(source), [['all', 'All sources']]);
assert.equal(source.value, 'all');
assert.equal(folder.value, '');
bySource = { all: ['portraits', 'temp-only'], 0: ['portraits'], 1: ['temp-only'] };
refine.setOptions('source', [['all', 'All sources'], ['0', 'output'], ['1', 'archive']]);
// The values it opened with, now offered, and the chosen Source's folders.
assert.deepEqual(optionPairs(source).map(([value]) => value), ['all', '0', '1']);
assert.equal(source.value, '1');
assert.deepEqual(optionPairs(folder), [['', 'All output folders'], ['temp-only', 'temp-only']]);
assert.equal(folder.value, 'temp-only');
// A choice made since wins over the opening value; tags and unknown keys are ignored.
source.value = '0';
source.dispatchEvent(new Event('change'));
refine.setOptions('source', [['all', 'All sources'], ['0', 'output'], ['1', 'archive']]);
refine.setOptions('tags', [['x', 'x']]);
assert.equal(source.value, '0');
assert.deepEqual(optionPairs(folder).map(([value]) => value), ['', 'portraits']);
assert.equal(media.value, 'video');
button(byId(dom, 'modal-body'), 'Apply filters').click();
assert.deepEqual(applied, [{ source: '0', folder: '', media: 'video' }]);
// Closed: a late discovery leaves the shell alone.
refine.setOptions('source', [['all', 'All sources']]);
assert.equal(source.options.length, 3);
// Replaced by another dialog: the same.
const again = openRefineDialog({ scope, fields, values: {}, onApply() {}, onClear() {} });
openImportDialog({ scope, onInspect() {}, onRescan() {} });
again.setOptions('source', [['all', 'All sources'], ['9', 'late']]);
assert.equal(byId(dom, 'modal-title').textContent, 'Import & inspect');
} finally {
closeWorkspaceDialog();
scope.dispose();
dom.restore();
}
});
test('the Folder list follows the chosen Source', async () => {
const { openRefineDialog, closeWorkspaceDialog } = await dialogsModule;
const dom = fakeDOM();
+8 -2
View File
@@ -133,6 +133,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
let state, geometry = { positions: [], columns: 1, height: 0, maxHeight: 0 }, galleryWidth = 0, optionsRequest, categoryRequest, recovering = false, rescanning = false;
// Discovered sources (each with its own folders) and every folder, for "Refine this view" and the scope chip.
let roots = [], folders = [];
// The open "Refine this view", so a discovery that settles after it opened still lists its sources and folders.
let refine = null;
const rendered = new Map();
const itemIndexes = new Map();
const filters = { root: 'all', q: '', category: '', tags: [], subfolder: '', media: 'all', sort: 'newest', ...visit?.filters, ...saved,
@@ -643,8 +645,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
/** "Refine this view": Source, Folder, Media and Tags, the filters the old field row held (D14). */
function openFilters(trigger) {
if (recovering) return;
openRefineDialog({scope, trigger, fields:[
{key:'source', options:[['all', 'All sources'], ...roots.map(root => [String(root.index), root.label])]},
refine = openRefineDialog({scope, trigger, fields:[
{key:'source', options:sourceOptions()},
{key:'folder', options:folderOptions('all'), optionsFor:folderOptions},
{key:'media', options:MEDIA_OPTIONS},
{key:'tags'},
@@ -654,6 +656,9 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
applyRefinement(cleared);
}});
}
function sourceOptions() {
return [['all', 'All sources'], ...roots.map(root => [String(root.index), root.label])];
}
/** The chosen Source's folders, as the old field row reloaded them; every folder under All sources. */
function folderOptions(source) {
const root = source === 'all' ? null : roots.find(item => String(item.index) === source);
@@ -750,6 +755,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
roots = data.roots.filter(root => Number.isSafeInteger(root?.index) && typeof root.label === 'string')
.map(root => ({index:root.index, label:root.label, subfolders:paths(root.subfolders)}));
folders = paths(data.subfolders);
refine?.setOptions('source', sourceOptions());
updateScope();
sourcesRetry = null;
return true;
+21 -4
View File
@@ -152,6 +152,9 @@ function choose(select, value) {
* Tags; Folder may add `optionsFor(source)`, the options of one Source, so its list follows the chosen Source and a
* folder that Source lacks returns to the first option. `values` holds the current value per key. Apply reports the edited values; Clear resets every field and
* reports the cleared values plus `category: ''`, so the page clears its toolbar category too. Both close first.
* Returns `{setOptions(key, options)}` for options discovered after the dialog opened (a slow folder discovery): the
* select is refilled and shows the value the field opened with, or the one chosen since, once it is offered; a Folder
* that follows its Source is refilled from `optionsFor` again. It does nothing once this dialog closed or was replaced.
*/
export function openRefineDialog({ scope, fields, values = {}, onApply, onClear, trigger = null }) {
requireScope(scope);
@@ -172,16 +175,21 @@ export function openRefineDialog({ scope, fields, values = {}, onApply, onClear,
choose(select, values[key]);
return [{ key, control: select, cleared: item.options[0][0], optionsFor: item.optionsFor }];
});
// Each select's wanted value: the one it opened with until the person picks another (see setOptions).
const wanted = new Map(controls.map(({ key }) => [key, values[key]]));
for (const { key, control } of controls) {
if (key !== 'tags') control.addEventListener('change', () => wanted.set(key, control.value));
}
const source = controls.find(item => item.key === 'source');
const folder = controls.find(item => item.key === 'folder' && typeof item.optionsFor === 'function');
const followSource = () => {
const chosen = folder.control.value;
const followSource = (chosen = folder.control.value) => {
fillSelect(folder.control, 'folder', folder.optionsFor(source.control.value));
choose(folder.control, chosen);
};
if (source && folder) {
followSource();
source.control.addEventListener('change', followSource);
followSource(values.folder);
// A Source that lacks the chosen folder clears it, and that cleared folder is the wanted one from then on.
source.control.addEventListener('change', () => { followSource(); wanted.set('folder', folder.control.value); });
}
const read = () => Object.fromEntries(controls.map(({ key, control }) =>
[key, key === 'tags' ? control.value.trim() : control.value]));
@@ -207,6 +215,15 @@ export function openRefineDialog({ scope, fields, values = {}, onApply, onClear,
...controls.map(({ key, control }) => field(FIELD_LABELS[key], control, `filter-${key}`)),
actions,
], { scope, trigger });
const opened = session;
function setOptions(key, options) {
const item = controls.find(candidate => candidate.key === key);
if (session !== opened || !item || key === 'tags') return;
fillSelect(item.control, key, options);
choose(item.control, wanted.get(key));
if (source && folder && (item === source || item === folder)) followSource(wanted.get('folder'));
}
return { setOptions };
}
/** One operation row; a press on its busy (aria-disabled) action is ignored and keeps the dialog open. */