Files
ComfyAssets-ComfyUI_PromptM…/tests/js/workspace-toolbar.test.js
T
Vito e9996996e2 feat(views): saved views and Favorites on Images and Library (#268)
* feat(views): saved-views store and API

Add a saved_views table (page images|library, trimmed 1-100 character
name, whitelisted JSON state of at most 4 KB, position, timestamps)
installed through the same validated-savepoint migration as image
favorites. It is optional in old backups and required in a migrated
restore stage; restore rejects a tampered definition, extra indexes and
any row that would fail write validation.

Routes under /prompt_manager/saved-views: GET lists views by position,
POST {page,name,state} returns 201 {success, view}, PATCH /{id} {name}
renames and DELETE /{id} removes, both 404 for an unknown id. The state
must hold exactly the page's toolbar and Refine-this-view keys (Images:
q, category, sort, source, folder, media, tags; Library: q, category,
sort, tags) with their types and sort/media values; anything else is a
400.

Refs #249

* feat(views): Save view, the SAVED VIEWS sidebar and opening a view

The shared toolbar gains the concept's Save view between sort and Filters
on Images and the Prompt library. It opens the concept's simpleInput
("Save this view", "View name", default "Search: <q>" or "My workspace
view", Cancel / Save); an empty name is "Saved view". A save posts the
page's full whitelisted state (toolbar value plus Refine filters) and
toasts "Saved view added to the sidebar.".

The shell adds the concept's SAVED VIEWS label and nav#saved-nav, loaded
with the shell and re-read after every save. Rows use the concept's
links() markup (search icon, name, no count) and link #/saved/<id>.
Opening one mounts its page with the saved state through the page's own
filters, so the toolbar and "Filters · N" (D44) show it; the h1,
breadcrumb and title are the view's name. A removed Source or Folder is
dropped to its default after discovery, so no listing read is rejected;
an unknown id re-reads the list once, then opens Images; a failed list
read offers Retry.

D16 is retired: images-concept and library-concept no longer map Save
view out. New deviations: D49 (no PROPOSED badge) and D50 (no Needs
review row); shell-concept maps Favorites out until Task 3 and compares
a saved row against the concept's. Browser fixtures now wipe
saved_views.

* feat(views): Edit rename/delete and the Favorites saved view

- Favorites is the first SAVED VIEWS row (star icon, name, real count).
  The count is the Favorites listing's own limit=1 total, follows the
  nav-count rules and is re-read after every confirmed star or unstar.
- #/favorites opens Images filtered to favorites. The workspace listing
  takes favorites=1: each selected root's favorites are read through the
  image_favorites primary key and matched against the cached scan with the
  toggle's case folding; a failed read is an error, never an unfiltered
  page. The cursor scope includes the flag.
- Saved Images state gains a required favorites boolean, so Save view on
  Favorites reopens the favorites.
- An open saved view's heading leads with Edit: "Edit saved view" with
  View name, Delete... (danger), Cancel and Save. Delete... confirms in the
  same dialog (never window.confirm). Toasts "Saved view renamed." and
  "Saved view deleted."; a delete returns to the view's plain page.
- shell-concept no longer maps Favorites out (Needs review stays, D50).
  Recorded as D51 in TESTING.md.

* fix(views): open stale saved views safely and finish the review fixes

Startup and restore now check only the saved_views structure (table,
columns, types, sizes, a JSON object state) and never the value rules,
so a row saved under older rules no longer stops the database from
opening. Reads normalize each view: missing keys take their defaults,
unknown keys are dropped, invalid values default and the name is made
displayable. POST and PATCH still validate strictly.

Only request validation (SavedViewInvalid) maps to 400; database and
internal errors are a logged generic 500. A saved view no longer
replaces the plain Images or Library return state, a toolbar change
while sources load keeps a saved Source and Folder, a state over 4 KB
is refused in the browser with its own toast, the delete confirmation
skips its failure toast after the page is gone, the Edit dialog reuses
the shared field helper, and quick star toggles coalesce into one
Favorites count read.

* test(views): cover leaving a pending saved view and add its coverage floor

A saved view left while the list or a rename is still pending leaves
the next page alone; the shell drops two unreachable heading guards.
workspace/saved-views.mjs gets its frontend coverage baseline entry;
no existing floor changes.

* fix(views): a corrupt saved-view state no longer blocks startup

At startup a saved_views row whose state is not a JSON object is
logged as a warning by id only and left in place. The listing and
rename skip it, so its #/saved/<id> link behaves like a missing view.
Restore stays strict and still rejects a backup holding such a row.
2026-10-21 09:10:18 -07:00

725 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// The shared concept toolbar and dialogs (#249 C1): concept app.js toolbar() a347–360, "Refine this view"
// a1348–1364, "Import & inspect" a1483–1491 (D15) and the "Copy prompt" fallback a1223–1241.
const test = require('node:test');
const assert = require('node:assert/strict');
const toolbarModule = import('../../web/js/workspace/toolbar.mjs');
const dialogsModule = import('../../web/js/workspace/workspace-dialogs.mjs');
function optionsCollection(list) {
const collection = { length: list.length, item: index => list[index] ?? null, [Symbol.iterator]: () => list[Symbol.iterator]() };
list.forEach((option, index) => { collection[index] = option; });
return Object.freeze(collection);
}
/** A minimal DOM: enough element behavior for markup, events, focus and dialogs. */
function fakeDOM() {
const previous = { document: global.document };
let active = null;
class Element extends EventTarget {
constructor(tag) {
super();
this.tagName = tag.toUpperCase();
this.children = [];
this.attributes = {};
this.parentNode = null;
this.className = '';
this.id = '';
this.value = '';
this.disabled = false;
this.open = false;
this.selected = false;
this.selectionStart = 0;
this.selectionEnd = 0;
this._text = '';
}
get textContent() { return this._text + this.children.map(child => child.textContent).join(''); }
set textContent(text) { this.children = []; this._text = String(text); }
get isConnected() { return this === body || Boolean(this.parentNode?.isConnected); }
// Like a browser's HTMLOptionsCollection: indexed and iterable, but not an Array (no .some/.map).
get options() { return optionsCollection(this.children.filter(child => child.tagName === 'OPTION')); }
append(...nodes) {
for (const node of nodes) {
node.parentNode?.children.splice(node.parentNode.children.indexOf(node), 1);
node.parentNode = this;
this.children.push(node);
}
}
replaceChildren(...nodes) {
for (const child of this.children) child.parentNode = null;
this.children = [];
this._text = '';
this.append(...nodes);
}
remove() {
if (!this.parentNode) return;
this.parentNode.children.splice(this.parentNode.children.indexOf(this), 1);
this.parentNode = null;
}
setAttribute(name, value) { this.attributes[name] = String(value); }
getAttribute(name) { return Object.hasOwn(this.attributes, name) ? this.attributes[name] : null; }
removeAttribute(name) { delete this.attributes[name]; }
focus() { active = this; }
select() { this.selectionStart = 0; this.selectionEnd = this.value.length; }
showModal() { this.open = true; }
close() {
if (!this.open) return;
this.open = false;
// Browsers queue the close event as a later task.
setTimeout(() => this.dispatchEvent(new Event('close')), 0);
}
click() { this.dispatchEvent(new Event('click', { cancelable: true })); }
}
const body = new Element('body');
global.document = {
body,
get activeElement() { return active ?? body; },
createElement: tag => new Element(tag),
createElementNS: (namespace, tag) => new Element(tag),
};
const all = (root, predicate, found = []) => {
if (predicate(root)) found.push(root);
for (const child of root.children) all(child, predicate, found);
return found;
};
return {
body, Element,
all: predicate => all(body, predicate),
find: (root, predicate) => all(root, predicate)[0],
restore() { Object.assign(global, previous); },
};
}
const byId = (dom, id) => dom.all(node => node.id === id)[0];
const tags = node => node.children.map(child => child.tagName.toLowerCase());
const optionPairs = select => [...select.options].map(option => [option.value, option.textContent]);
const modal = dom => byId(dom, 'modal');
const tick = () => new Promise(resolve => setTimeout(resolve, 5));
const coreModule = import('../../web/js/shared/toolkit/core.js');
const button = (root, text) => {
const found = [];
const walk = node => {
if (node.tagName === 'BUTTON' && node.textContent === text) found.push(node);
node.children.forEach(walk);
};
walk(root);
return found[0];
};
test('toolbar emits concept markup and order', async () => {
const { mountToolbar } = await toolbarModule;
const { createScope } = await import('../../web/js/shared/toolkit/core.js');
const dom = fakeDOM();
const scope = createScope();
try {
const container = new dom.Element('div');
dom.body.append(container);
const changes = [];
const filters = [];
const saves = [];
const toolbar = mountToolbar(container, {
scope, categories: ['Portraits', 'Landscapes'],
sorts: [['newest', 'Recently added'], ['name', 'Name A–Z'], ['rating', 'Highest rated']],
value: { q: 'castle', category: 'Landscapes', sort: 'name' },
onChange: value => changes.push(value), onFilters: trigger => filters.push(trigger),
onSaveView: trigger => saves.push(trigger),
});
const root = container.children[0];
assert.equal(toolbar.element, root);
assert.equal(root.tagName, 'SEARCH');
assert.equal(root.className, 'toolbar');
assert.deepEqual(tags(root), ['label', 'select', 'select', 'button', 'button']);
const [label, category, sort, saveButton, filtersButton] = root.children;
assert.equal(label.className, 'search-field');
assert.equal(label.htmlFor, 'search');
assert.deepEqual(tags(label), ['svg', 'input']);
assert.equal(label.children[0].getAttribute('class'), 'icon');
assert.equal(label.children[0].getAttribute('aria-hidden'), 'true');
const input = label.children[1];
assert.equal(input.id, 'search');
assert.equal(input.type, 'search');
assert.equal(input.placeholder, 'Search prompts, tags, filenames…');
// The listing API rejects search text over 500 characters, as the legacy search capped it.
assert.equal(input.maxLength, 500);
assert.equal(input.getAttribute('aria-label'), 'Search workspace');
assert.equal(input.value, 'castle');
assert.equal(category.id, 'category-filter');
assert.equal(category.getAttribute('aria-label'), 'Filter by category');
assert.deepEqual(optionPairs(category), [['', 'All categories'], ['Portraits', 'Portraits'], ['Landscapes', 'Landscapes']]);
assert.equal(category.value, 'Landscapes');
assert.equal(sort.id, 'sort');
assert.equal(sort.getAttribute('aria-label'), 'Sort results');
assert.equal(sort.value, 'name');
// The concept's Save view, between sort and Filters (a359), reports its own button as the trigger.
assert.equal(button(root, 'Save view'), saveButton);
assert.equal(saveButton.type, 'button');
saveButton.click();
assert.deepEqual(saves, [saveButton]);
assert.deepEqual(filters, []);
assert.equal(filtersButton.textContent, 'Filters');
assert.equal(filtersButton.type, 'button');
filtersButton.click();
assert.deepEqual(filters, [filtersButton]);
category.value = '';
category.dispatchEvent(new Event('change'));
sort.value = 'rating';
sort.dispatchEvent(new Event('change'));
assert.deepEqual(changes, [
{ q: 'castle', category: '', sort: 'name' },
{ q: 'castle', category: '', sort: 'rating' },
]);
// Categories refresh without dropping a still-listed selection.
toolbar.setCategories(['Archive']);
assert.deepEqual(optionPairs(category), [['', 'All categories'], ['Archive', 'Archive']]);
toolbar.setValue({ q: 'moon', category: 'Archive', sort: 'newest' });
assert.deepEqual([input.value, category.value, sort.value], ['moon', 'Archive', 'newest']);
assert.deepEqual(toolbar.value(), { q: 'moon', category: 'Archive', sort: 'newest' });
// A still-listed selection survives a category refresh.
toolbar.setCategories(['Portraits', 'Archive']);
assert.equal(category.value, 'Archive');
assert.deepEqual(optionPairs(category), [['', 'All categories'], ['Portraits', 'Portraits'], ['Archive', 'Archive']]);
// A selection that is no longer listed drops to All categories.
toolbar.setCategories(['Portraits']);
assert.equal(category.value, '');
// An unknown sort falls back to the first option instead of an empty select.
toolbar.setValue({ sort: 'bogus' });
assert.equal(sort.value, 'newest');
assert.throws(() => mountToolbar(container, { categories: [], sorts: [['newest', 'Recently added']],
onChange() {}, onFilters() {}, onSaveView() {} }), TypeError);
// Every toolbar page offers Save view, so the callback is required.
assert.throws(() => mountToolbar(container, { scope, categories: [], sorts: [['newest', 'Recently added']],
onChange() {}, onFilters() {} }), TypeError);
} finally {
scope.dispose();
dom.restore();
}
});
test('toolbar search uses a debounced onChange that stops with its scope', async () => {
const { mountToolbar, SEARCH_DELAY } = await toolbarModule;
const { createScope } = await import('../../web/js/shared/toolkit/core.js');
const dom = fakeDOM();
const scope = createScope();
try {
const container = new dom.Element('div');
const changes = [];
mountToolbar(container, { scope, categories: [], sorts: [['newest', 'Recently added']],
value: { q: '', category: '', sort: 'newest' }, onChange: value => changes.push(value), onFilters() {}, onSaveView() {} });
const input = container.children[0].children[0].children[1];
assert.equal(input.id, 'search');
for (const text of ['c', 'ca', 'cat']) {
input.value = text;
input.dispatchEvent(new Event('input'));
}
assert.deepEqual(changes, []);
await new Promise(resolve => setTimeout(resolve, SEARCH_DELAY + 30));
assert.deepEqual(changes, [{ q: 'cat', category: '', sort: 'newest' }]);
input.value = 'dog';
input.dispatchEvent(new Event('input'));
scope.dispose();
await new Promise(resolve => setTimeout(resolve, SEARCH_DELAY + 30));
assert.equal(changes.length, 1);
assert.equal(container.children.length, 0);
} finally {
scope.dispose();
dom.restore();
}
});
test('sort lists concept options first', async () => {
const { mountToolbar, orderSorts } = await toolbarModule;
const { createScope } = await import('../../web/js/shared/toolkit/core.js');
// D14: the concept's three, in the concept's order, then the page's appended options in their own order.
const given = [['newest', 'Recently added'], ['oldest', 'Oldest first'], ['name-desc', 'Filename Z–A'],
['rating', 'Highest rated'], ['name', 'Name A–Z']];
assert.deepEqual(orderSorts(given), [['newest', 'Recently added'], ['name', 'Name A–Z'], ['rating', 'Highest rated'],
['oldest', 'Oldest first'], ['name-desc', 'Filename Z–A']]);
// A page without a concept option keeps the ones it has, concept first.
assert.deepEqual(orderSorts([['oldest', 'Oldest first'], ['newest', 'Recently added']]),
[['newest', 'Recently added'], ['oldest', 'Oldest first']]);
// Matching is by the concept's exact labels (en dash): a variant label is an appended option.
assert.deepEqual(orderSorts([['name', 'Name A-Z'], ['newest', 'Recently added']]),
[['newest', 'Recently added'], ['name', 'Name A-Z']]);
assert.throws(() => orderSorts([]), TypeError);
assert.throws(() => orderSorts([['newest']]), TypeError);
const dom = fakeDOM();
const scope = createScope();
try {
const container = new dom.Element('div');
mountToolbar(container, { scope, categories: [], sorts: given, value: { q: '', category: '', sort: 'oldest' },
onChange() {}, onFilters() {}, onSaveView() {} });
const sort = container.children[0].children[2];
assert.deepEqual(optionPairs(sort).map(([, label]) => label),
['Recently added', 'Name A–Z', 'Highest rated', 'Oldest first', 'Filename Z–A']);
assert.equal(sort.value, 'oldest');
} finally {
scope.dispose();
dom.restore();
}
});
const REFINE_FIELDS = [
{ key: 'source', options: [['all', 'All sources'], ['0', 'output'], ['1', 'archive']] },
{ key: 'folder', options: [['', 'All output folders'], ['portraits', 'portraits']] },
{ key: 'media', options: [['all', 'All media'], ['image', 'Images'], ['video', 'Videos']] },
{ key: 'tags' },
];
test('refine dialog round-trips values and clear resets them', async () => {
const { openRefineDialog, closeWorkspaceDialog } = await dialogsModule;
const dom = fakeDOM();
const scope = (await coreModule).createScope();
try {
const trigger = new dom.Element('button');
dom.body.append(trigger);
const applied = [];
const cleared = [];
const values = { source: '1', folder: 'portraits', media: 'video', tags: 'cinematic' };
openRefineDialog({ scope, fields: REFINE_FIELDS, values, trigger,
onApply: value => applied.push(value), onClear: value => cleared.push(value) });
const dialog = modal(dom);
assert.equal(dialog.open, true);
assert.equal(byId(dom, 'modal-title').textContent, 'Refine this view');
const body = byId(dom, 'modal-body');
const fields = body.children.filter(child => child.className === 'field');
assert.deepEqual(fields.map(field => field.children[0].textContent), ['Source', 'Folder', 'Media', 'Tags']);
assert.deepEqual(fields.map(field => field.children[0].htmlFor), ['filter-source', 'filter-folder', 'filter-media', 'filter-tags']);
assert.deepEqual(fields.map(field => field.children[1].id), ['filter-source', 'filter-folder', 'filter-media', 'filter-tags']);
const [source, folder, media, tagsInput] = fields.map(field => field.children[1]);
assert.deepEqual(optionPairs(source), REFINE_FIELDS[0].options);
assert.deepEqual([source.value, folder.value, media.value, tagsInput.value], ['1', 'portraits', 'video', 'cinematic']);
assert.equal(tagsInput.placeholder, 'e.g. cinematic');
// D14: no mock-status note, because every field is a real filter.
assert.deepEqual(tags(body), ['div', 'div', 'div', 'div', 'div']);
assert.equal(body.children.some(child => child.tagName === 'P'), false);
const actions = body.children[4];
assert.equal(actions.className, 'dialog-actions');
assert.deepEqual(actions.children.map(child => [child.textContent, child.className]),
[['Clear filters', ''], ['Apply filters', 'ca-primary']]);
// Apply returns the values as edited and leaves the caller's object alone.
media.value = 'image';
tagsInput.value = ' moody ';
button(body, 'Apply filters').click();
assert.deepEqual(applied, [{ source: '1', folder: 'portraits', media: 'image', tags: 'moody' }]);
assert.equal(values.media, 'video');
assert.equal(dialog.open, false);
assert.equal(trigger.isConnected && document.activeElement, trigger);
// Reopening with the applied values shows them again (a round trip).
openRefineDialog({ scope, fields: REFINE_FIELDS, values: applied[0], trigger, onApply: value => applied.push(value), onClear() {} });
button(byId(dom, 'modal-body'), 'Apply filters').click();
assert.deepEqual(applied[1], applied[0]);
// Clear resets every field to its first option (Tags to empty) and reports that.
openRefineDialog({ scope, fields: REFINE_FIELDS, values: applied[0], trigger, onApply: value => applied.push(value),
onClear: value => cleared.push(value) });
const reopened = byId(dom, 'modal-body');
button(reopened, 'Clear filters').click();
// The concept's Clear also clears the toolbar category; the page applies `category`.
assert.deepEqual(cleared, [{ source: 'all', folder: '', media: 'all', tags: '', category: '' }]);
assert.equal(applied.length, 2);
assert.equal(modal(dom).open, false);
// A page offers only its own fields; the modal is the one shared shell.
openRefineDialog({ scope, fields: [{ key: 'tags' }], values: {}, onApply: value => applied.push(value), onClear() {} });
const only = byId(dom, 'modal-body').children.filter(child => child.className === 'field');
assert.deepEqual(only.map(field => field.children[0].textContent), ['Tags']);
button(byId(dom, 'modal-body'), 'Apply filters').click();
assert.deepEqual(applied[2], { tags: '' });
assert.equal(dom.all(node => node.id === 'modal').length, 1);
assert.throws(() => openRefineDialog({ scope, fields: [{ key: 'evidence' }], values: {}, onApply() {}, onClear() {} }), TypeError);
} finally {
closeWorkspaceDialog();
scope.dispose();
dom.restore();
}
});
test('the Folder list follows the chosen Source', async () => {
const { openRefineDialog, closeWorkspaceDialog } = await dialogsModule;
const dom = fakeDOM();
const scope = (await coreModule).createScope();
try {
const bySource = { all: ['portraits', 'temp-only'], 0: ['portraits'], 1: ['temp-only'] };
const optionsFor = source => [['', 'All output folders'], ...(bySource[source] ?? []).map(path => [path, path])];
const fields = [REFINE_FIELDS[0], { key: 'folder', options: optionsFor('all'), optionsFor }, REFINE_FIELDS[2]];
const applied = [];
openRefineDialog({ scope, fields, values: { source: '1', folder: 'temp-only', media: 'all' },
onApply: value => applied.push(value), onClear() {} });
const [source, folder] = byId(dom, 'modal-body').children.filter(child => child.className === 'field')
.map(field => field.children[1]);
// Opened on a chosen Source: only its folders.
assert.deepEqual(optionPairs(folder), [['', 'All output folders'], ['temp-only', 'temp-only']]);
assert.equal(folder.value, 'temp-only');
// Another Source drops a folder it does not have.
source.value = '0';
source.dispatchEvent(new Event('change'));
assert.deepEqual(optionPairs(folder), [['', 'All output folders'], ['portraits', 'portraits']]);
assert.equal(folder.value, '');
folder.value = 'portraits';
// A folder the next Source also has stays chosen.
source.value = 'all';
source.dispatchEvent(new Event('change'));
assert.deepEqual(optionPairs(folder).map(([value]) => value), ['', 'portraits', 'temp-only']);
assert.equal(folder.value, 'portraits');
button(byId(dom, 'modal-body'), 'Apply filters').click();
assert.deepEqual(applied, [{ source: 'all', folder: 'portraits', media: 'all' }]);
} finally {
closeWorkspaceDialog();
scope.dispose();
dom.restore();
}
});
test('a running rescan marks Rescan busy and ignores a second press', async () => {
const { openImportDialog, closeWorkspaceDialog } = await dialogsModule;
const dom = fakeDOM();
const scope = (await coreModule).createScope();
try {
const calls = [];
const handle = openImportDialog({ scope, rescanBusy: true, onInspect: () => calls.push('inspect'),
onRescan: () => calls.push('rescan') });
const body = byId(dom, 'modal-body');
const busy = button(body, 'Rescanning…');
assert.ok(busy, 'the busy Rescan says it is running');
assert.equal(busy.getAttribute('aria-disabled'), 'true');
busy.click();
assert.deepEqual(calls, []);
assert.equal(modal(dom).open, true, 'an ignored press keeps the dialog open');
handle.setRescanBusy(false);
assert.equal(busy.textContent, 'Rescan');
assert.equal(busy.getAttribute('aria-disabled'), null);
busy.click();
assert.deepEqual(calls, ['rescan']);
assert.equal(modal(dom).open, false);
} finally {
closeWorkspaceDialog();
scope.dispose();
dom.restore();
}
});
test('import dialog lists only real operations', async () => {
const { openImportDialog, closeWorkspaceDialog } = await dialogsModule;
const dom = fakeDOM();
const scope = (await coreModule).createScope();
try {
const calls = [];
openImportDialog({ scope, onInspect: () => calls.push('inspect'), onRescan: () => calls.push('rescan') });
assert.equal(byId(dom, 'modal-title').textContent, 'Import & inspect');
const body = byId(dom, 'modal-body');
assert.deepEqual(tags(body), ['p', 'div']);
assert.equal(body.children[0].className, 'small');
const list = body.children[1];
assert.equal(list.className, 'operation-list');
const rows = list.children.map(row => {
assert.equal(row.className, 'operation');
const [text, actions] = row.children;
assert.deepEqual(tags(text), ['h3', 'p']);
assert.equal(actions.className, 'row-actions');
return [text.children[0].textContent, text.children[1].textContent, actions.children[0].textContent];
});
// D15: no "Import portable recipes" until import exists; Rescan finds added or removed files.
assert.deepEqual(rows, [
['Inspect a local image', 'Read embedded metadata before deciding to add it.', 'Open inspector'],
['Scan an output folder', 'Find files added or removed in the output folders.', 'Rescan'],
]);
assert.doesNotMatch(body.textContent, /recipe|portable|sample/i);
button(body, 'Rescan').click();
assert.deepEqual(calls, ['rescan']);
assert.equal(modal(dom).open, false);
openImportDialog({ scope, onInspect: () => calls.push('inspect'), onRescan: () => calls.push('rescan') });
button(byId(dom, 'modal-body'), 'Open inspector').click();
assert.deepEqual(calls, ['rescan', 'inspect']);
assert.equal(modal(dom).open, false);
} finally {
closeWorkspaceDialog();
scope.dispose();
dom.restore();
}
});
test('copy fallback selects the text', async () => {
const { openCopyFallback, closeWorkspaceDialog } = await dialogsModule;
const dom = fakeDOM();
const scope = (await coreModule).createScope();
try {
const text = '<b>a castle</b>, "moonlight" & fog';
openCopyFallback(text, { scope });
assert.equal(byId(dom, 'modal-title').textContent, 'Copy prompt');
const body = byId(dom, 'modal-body');
assert.deepEqual(tags(body), ['div', 'p']);
const [field, note] = body.children;
assert.equal(field.className, 'field');
const [label, area] = field.children;
assert.equal(label.textContent, 'Select and copy this text');
assert.equal(label.htmlFor, 'copy-text');
assert.equal(area.tagName, 'TEXTAREA');
assert.equal(area.id, 'copy-text');
assert.equal(area.readOnly, true);
assert.equal(area.value, text);
assert.deepEqual([area.selectionStart, area.selectionEnd], [0, text.length]);
assert.equal(document.activeElement, area);
assert.equal(note.className, 'small');
assert.equal(note.textContent, 'Automatic clipboard access requires a secure browser context.');
assert.throws(() => openCopyFallback(42, { scope }), TypeError);
} finally {
closeWorkspaceDialog();
scope.dispose();
dom.restore();
}
});
// The concept's Save view dialog (a1330–1346 via simpleInput a1138): an empty name falls back to "Saved view".
test('an input dialog with a fallback applies it for an empty value', async () => {
const { openInputDialog, closeWorkspaceDialog } = await dialogsModule;
const dom = fakeDOM();
const scope = (await coreModule).createScope();
try {
const applied = [];
const open = value => openInputDialog({ scope, title: 'Save this view', label: 'View name', action: 'Save',
value, maxLength: 100, fallback: 'Saved view', onApply: name => applied.push(name) });
open('My workspace view');
assert.equal(byId(dom, 'modal-title').textContent, 'Save this view');
const body = byId(dom, 'modal-body');
const [field, actions] = body.children;
const [label, input] = field.children;
assert.equal(label.textContent, 'View name');
assert.equal(input.id, 'action-input');
assert.equal(input.value, 'My workspace view');
assert.equal(input.maxLength, 100);
assert.deepEqual(actions.children.map(node => node.textContent), ['Cancel', 'Save']);
input.value = ' ';
actions.children[1].click();
assert.deepEqual(applied, ['Saved view']);
open('');
byId(dom, 'action-input').value = ' Castles ';
byId(dom, 'modal-body').children[1].children[1].click();
assert.deepEqual(applied, ['Saved view', 'Castles']);
assert.throws(() => openInputDialog({ scope, title: 'T', label: 'L', action: 'A', fallback: '', onApply() {} }), TypeError);
} finally {
closeWorkspaceDialog();
scope.dispose();
dom.restore();
}
});
test('dialogs restore focus to the trigger on Escape', async () => {
const { openRefineDialog, openImportDialog, openCopyFallback, closeWorkspaceDialog } = await dialogsModule;
const dom = fakeDOM();
const scope = (await coreModule).createScope();
try {
const opens = [
trigger => openRefineDialog({ scope, fields: [{ key: 'tags' }], values: {}, trigger, onApply() {}, onClear() {} }),
trigger => openImportDialog({ scope, trigger, onInspect() {}, onRescan() {} }),
trigger => openCopyFallback('text', { trigger, scope }),
];
for (const open of opens) {
const trigger = new dom.Element('button');
dom.body.append(trigger);
open(trigger);
const dialog = modal(dom);
assert.equal(dialog.open, true);
assert.equal(dialog.getAttribute('aria-labelledby'), 'modal-title');
const header = dialog.children[0];
assert.equal(header.className, 'dialog-header');
assert.deepEqual(tags(header), ['h2', 'button']);
const close = header.children[1];
assert.equal(close.id, 'modal-close');
assert.equal(close.textContent, '×');
assert.equal(close.getAttribute('aria-label'), 'Close dialog');
assert.notEqual(document.activeElement, trigger);
// Escape fires `cancel` on a modal dialog.
const escape = new Event('cancel', { cancelable: true });
dialog.dispatchEvent(escape);
assert.equal(dialog.open, false);
assert.equal(document.activeElement, trigger);
}
// × closes and restores focus too; a removed trigger leaves focus alone.
const trigger = new dom.Element('button');
dom.body.append(trigger);
openImportDialog({ scope, trigger, onInspect() {}, onRescan() {} });
byId(dom, 'modal-close').click();
assert.equal(modal(dom).open, false);
assert.equal(document.activeElement, trigger);
openImportDialog({ scope, trigger, onInspect() {}, onRescan() {} });
trigger.remove();
const close = byId(dom, 'modal-close');
close.click();
assert.notEqual(document.activeElement, trigger);
// Opening over an open dialog swaps its content and keeps the first trigger; dispose removes the shell.
const first = new dom.Element('button');
dom.body.append(first);
openImportDialog({ scope, trigger: first, onInspect() {}, onRescan() {} });
openCopyFallback('swap', { scope });
assert.equal(byId(dom, 'modal-title').textContent, 'Copy prompt');
modal(dom).dispatchEvent(new Event('cancel', { cancelable: true }));
assert.equal(document.activeElement, first);
openImportDialog({ scope, trigger: first, onInspect() {}, onRescan() {} });
closeWorkspaceDialog();
assert.equal(modal(dom), undefined);
} finally {
closeWorkspaceDialog();
scope.dispose();
dom.restore();
}
});
test('an action may open the next dialog without the late close event closing it', async () => {
const { openImportDialog, openCopyFallback, closeWorkspaceDialog } = await dialogsModule;
const dom = fakeDOM();
const scope = (await coreModule).createScope();
try {
const first = new dom.Element('button');
const second = new dom.Element('button');
dom.body.append(first, second);
openImportDialog({ scope, trigger: first, onInspect() {},
onRescan: () => openCopyFallback('next', { scope, trigger: second }) });
button(byId(dom, 'modal-body'), 'Rescan').click();
await tick();
assert.equal(modal(dom).open, true);
assert.equal(byId(dom, 'modal-title').textContent, 'Copy prompt');
assert.equal(document.activeElement, byId(dom, 'copy-text'));
// Its own Escape still restores focus to its own trigger.
modal(dom).dispatchEvent(new Event('cancel', { cancelable: true }));
await tick();
assert.equal(document.activeElement, second);
// Replacing the shell ignores the old shell's late close event too.
openImportDialog({ scope, trigger: first, onInspect() {}, onRescan() {} });
closeWorkspaceDialog();
openImportDialog({ scope, trigger: second, onInspect() {}, onRescan() {} });
await tick();
assert.equal(modal(dom).open, true);
assert.equal(byId(dom, 'modal-title').textContent, 'Import & inspect');
} finally {
closeWorkspaceDialog();
scope.dispose();
dom.restore();
}
});
test('a content dialog shows page nodes and releases them once on close or replacement', async () => {
const { openContentDialog, openCopyFallback, closeWorkspaceDialog } = await dialogsModule;
const dom = fakeDOM();
const scope = (await coreModule).createScope();
try {
const trigger = new dom.Element('button');
dom.body.append(trigger);
let closed = 0;
const grid = new dom.Element('div');
openContentDialog({ scope, title: 'All images for this positive', nodes: [grid], trigger, onClose: () => closed++ });
assert.equal(byId(dom, 'modal-title').textContent, 'All images for this positive');
assert.deepEqual(byId(dom, 'modal-body').children, [grid]);
assert.equal(modal(dom).open, true);
modal(dom).dispatchEvent(new Event('cancel', { cancelable: true }));
assert.equal(closed, 1);
assert.equal(document.activeElement, trigger);
await tick();
assert.equal(closed, 1);
// Another dialog replacing the body releases it too, exactly once.
openContentDialog({ scope, title: 'Repeat run', nodes: [new dom.Element('p')], trigger, onClose: () => closed++ });
openCopyFallback('swap', { scope });
assert.equal(closed, 2);
closeWorkspaceDialog();
assert.equal(closed, 2);
// Disposing the page scope closes it.
openContentDialog({ scope, title: 'Repeat run', nodes: [], onClose: () => closed++ });
scope.dispose();
assert.equal(closed, 3);
assert.throws(() => openContentDialog({ title: 'x', nodes: [] }), /page scope/);
const other = (await coreModule).createScope();
assert.throws(() => openContentDialog({ scope: other, title: '', nodes: [] }), TypeError);
assert.throws(() => openContentDialog({ scope: other, title: 'x', nodes: [null] }), TypeError);
assert.throws(() => openContentDialog({ scope: other, title: 'x', nodes: [], onClose: 1 }), TypeError);
other.dispose();
} finally {
closeWorkspaceDialog();
scope.dispose();
dom.restore();
}
});
test('dialogs need the page scope and close with it', async () => {
const { openRefineDialog, openImportDialog, openCopyFallback, closeWorkspaceDialog } = await dialogsModule;
const { createScope } = await coreModule;
const dom = fakeDOM();
try {
assert.throws(() => openImportDialog({ onInspect() {}, onRescan() {} }), /page scope/);
assert.throws(() => openRefineDialog({ fields: [], onApply() {}, onClear() {} }), /page scope/);
assert.throws(() => openCopyFallback('text'), /page scope/);
const scope = createScope();
assert.throws(() => openImportDialog({ scope, onInspect() {} }), TypeError);
assert.throws(() => openRefineDialog({ scope, fields: [], onApply() {} }), TypeError);
assert.equal(modal(dom), undefined);
// Disposing the page scope closes and removes the shell, however often the page opened it.
const trigger = new dom.Element('button');
dom.body.append(trigger);
openImportDialog({ scope, trigger, onInspect() {}, onRescan() {} });
openCopyFallback('again', { scope });
scope.dispose();
assert.equal(modal(dom), undefined);
assert.equal(document.activeElement, trigger);
// An old page's scope never closes the next page's dialog.
const older = createScope();
const newer = createScope();
openImportDialog({ scope: older, onInspect() {}, onRescan() {} });
openCopyFallback('next page', { scope: newer });
older.dispose();
assert.equal(modal(dom).open, true);
newer.dispose();
assert.equal(modal(dom), undefined);
} finally {
closeWorkspaceDialog();
dom.restore();
}
});
test('every route change closes the shared workspace dialog', async () => {
const { readFile } = require('node:fs/promises');
const path = require('node:path');
const source = await readFile(path.join(__dirname, '../../web/js/workspace/index.mjs'), 'utf8');
assert.match(source, /import \{[^}]*\bcloseWorkspaceDialog\b[^}]*\} from '\.\/workspace-dialogs\.mjs';/);
const render = source.slice(source.indexOf('function render(route) {'));
const body = render.slice(0, render.indexOf('viewScope = createScope();'));
assert.match(body, /closeWorkspaceDialog\(\);/, 'render closes the dialog before the next page mounts');
});
// D44: the concept has no indicator, so a filter set in "Refine this view" (or by Browse) was invisible on the page.
test('the Filters button shows the active refinement count', async () => {
const { mountToolbar } = await toolbarModule;
const { createScope } = await import('../../web/js/shared/toolkit/core.js');
const dom = fakeDOM();
const scope = createScope();
try {
const container = new dom.Element('div');
const toolbar = mountToolbar(container, { scope, categories: [], sorts: [['newest', 'Recently added']],
activeFilters: 2, onChange() {}, onFilters() {}, onSaveView() {} });
const filtersButton = container.children[0].children[4];
// The visible text is the accessible name, so the count is spoken too.
assert.equal(filtersButton.textContent, 'Filters · 2');
toolbar.setActiveFilters(1);
assert.equal(filtersButton.textContent, 'Filters · 1');
toolbar.setActiveFilters(0);
assert.equal(filtersButton.textContent, 'Filters');
for (const bad of [-1, 1.5, '1', null, Number.NaN]) assert.throws(() => toolbar.setActiveFilters(bad), TypeError);
assert.equal(filtersButton.textContent, 'Filters');
assert.throws(() => mountToolbar(new dom.Element('div'), { scope, categories: [], sorts: [['newest', 'Recently added']],
activeFilters: -1, onChange() {}, onFilters() {}, onSaveView() {} }), TypeError);
} finally {
scope.dispose();
dom.restore();
}
});