zotero/test/tests/queryTextboxTest.js
Dan Stillman 77a3a8815e
Some checks are pending
CI / Detect changes (push) Waiting to run
CI / Test () (push) Blocked by required conditions
CI / Test (macOS NFS) (push) Blocked by required conditions
CI / Test (Windows arm64) (push) Blocked by required conditions
CI / Test (Windows x64) (push) Blocked by required conditions
CI / Utilities Tests (push) Waiting to run
CI / Build, Upload (push) Waiting to run
Add a query syntax to the quick search
Zotero.SearchQuery turns a query like `by:smith after:2020 tag:"to read"
crispr` or `creator is smith and (tag is foo or bar)` into a
Zotero.Search, matching whatever text is left over using the current
search mode. Anything that doesn't look like a clause is free text, so a
DOI or a title with a colon in it is matched literally.

The search box syntax-highlights the parts of recognized conditions and
offers autocomplete for condition names, for the values of conditions
that have a fixed set of them (like item type), and for tags and
creators from the selected libraries.
2026-08-20 16:09:20 -04:00

159 lines
6.4 KiB
JavaScript

"use strict";
describe("query-textbox", function () {
var win, textbox;
before(async function () {
win = await loadZoteroPane();
textbox = win.document.getElementById('zotero-tb-search').searchTextbox;
});
after(async function () {
await Zotero.Tags.setColor(Zotero.Libraries.userLibraryID, 'zzcompletion', false);
textbox.value = '';
win.close();
});
it("should render the query in spans covering the whole value", function () {
textbox.value = 'by:smith crispr';
let layer = textbox.shadowRoot.querySelector('.query-highlight');
assert.equal(layer.textContent, 'by:smith crispr');
assert.deepEqual(
[...layer.children].map(span => span.className),
['query-token-field', 'query-token-operator', 'query-token-value',
'query-token-space', 'query-token-text']
);
assert.isTrue(textbox.hasAttribute('highlighted'));
});
it("should leave a query without conditions to the input", function () {
textbox.value = 'just some words';
assert.isFalse(textbox.hasAttribute('highlighted'));
assert.equal(
textbox.shadowRoot.querySelector('.query-highlight').textContent, ''
);
});
it("should offer tags and creators from the library", async function () {
let item = await createDataObject('item', { tags: [{ tag: 'zzcompletion' }] });
item.setCreator(0, { firstName: 'Zora', lastName: 'Zzcompletion', creatorType: 'author' });
// A two-field creator with only a last name, whose value has no
// leading space to quote
item.setCreator(1, { firstName: '', lastName: 'Zzlastonly', creatorType: 'author' });
await item.saveTx();
// updateCompletions() resolves once the library lookup has filled
// the list
let offered = async (query, value) => {
textbox.value = query;
textbox.inputField.setSelectionRange(query.length, query.length);
await textbox.updateCompletions();
let list = win.document.querySelector('.query-completions richlistbox');
assert.isTrue(
[...list.children].some(row => row.completion.label === value), value
);
};
await Zotero.Tags.setColor(Zotero.Libraries.userLibraryID, 'zzcompletion', '#FF6666');
await offered('tag:zzcomp', 'zzcompletion');
// A colored tag carries its color, for the swatch in the list
let list = win.document.querySelector('.query-completions richlistbox');
let row = [...list.children].find(r => r.completion.label === 'zzcompletion');
assert.equal(row.completion.color, '#FF6666');
await offered('by:zzcomp', 'Zora Zzcompletion');
await offered('by:zzlast', 'Zzlastonly');
});
it("should offer completions for a condition name and then its values", async function () {
textbox.value = 'ty';
textbox.inputField.setSelectionRange(2, 2);
textbox.updateCompletions();
let list = win.document.querySelector('.query-completions richlistbox');
// The list is shown the first time there's something to offer
assert.notEqual(win.document.querySelector('.query-completions').state, 'closed');
let labels = [...list.children].map(row => row.completion.label);
assert.include(labels, 'type:');
// Completing a condition name offers what it takes next
list.selectedIndex = labels.indexOf('type:');
textbox._acceptCompletion();
assert.equal(textbox.value, 'type:');
assert.equal(textbox.inputField.selectionStart, 'type:'.length);
labels = [...list.children].map(row => row.completion.label);
assert.include(labels, 'journal article');
// Arrowing up from nothing selected starts at the end of the list
textbox._handleCompletionKey(new win.KeyboardEvent('keydown', { key: 'ArrowUp' }));
assert.equal(list.selectedIndex, list.itemCount - 1);
list.selectedIndex = labels.indexOf('journal article');
textbox._acceptCompletion();
assert.equal(textbox.value, 'type:"journal article"');
assert.deepEqual(
Zotero.SearchQuery.parse(textbox.value).tree.children,
[{ condition: 'itemType', operator: 'is', value: 'journalArticle' }]
);
textbox.value = 'crispr ';
textbox.inputField.setSelectionRange(7, 7);
textbox.updateCompletions();
assert.equal(win.document.querySelector('.query-completions').state, 'closed');
});
it("should accept the completion under the mouse", async function () {
textbox.value = 'ty';
textbox.inputField.setSelectionRange(2, 2);
await textbox.updateCompletions();
let list = win.document.querySelector('.query-completions richlistbox');
let row = [...list.children].find(r => r.completion.label === 'type:');
row.dispatchEvent(new win.MouseEvent('mousedown', { bubbles: true }));
assert.equal(textbox.value, 'type:');
});
it("should not reopen the list after accepting a value", async function () {
await createDataObject('item', { tags: [{ tag: 'zzaccepted' }] });
textbox.value = 'tag:zzaccep';
textbox.inputField.setSelectionRange(11, 11);
await textbox.updateCompletions();
let list = win.document.querySelector('.query-completions richlistbox');
let index = [...list.children].findIndex(row => row.completion.label === 'zzaccepted');
assert.isAbove(index, -1);
list.selectedIndex = index;
// Control the follow-up lookup, so the result demonstrably arrives
// only after the acceptance has hidden the list
let resolveLookup;
let stub = sinon.stub(textbox, '_lookupValues').returns(new Promise((resolve) => {
resolveLookup = resolve;
}));
try {
textbox._acceptCompletion();
assert.equal(textbox.value, 'tag:zzaccepted');
resolveLookup([{ text: 'zzaccepted', label: 'zzaccepted' }]);
await Zotero.Promise.delay(0);
assert.include(['closed', 'hiding'],
win.document.querySelector('.query-completions').state);
}
finally {
stub.restore();
}
});
it("should close the completion list on Enter without a selection", async function () {
textbox.value = 'ty';
textbox.inputField.setSelectionRange(2, 2);
await textbox.updateCompletions();
assert.notEqual(win.document.querySelector('.query-completions').state, 'closed');
textbox._handleCompletionKey(new win.KeyboardEvent('keydown', { key: 'Enter' }));
assert.include(['closed', 'hiding'],
win.document.querySelector('.query-completions').state);
});
it("should hide completions when the value is set", async function () {
textbox.value = 'ty';
textbox.inputField.setSelectionRange(2, 2);
await textbox.updateCompletions();
assert.notEqual(win.document.querySelector('.query-completions').state, 'closed');
textbox.value = 'something else';
assert.include(['closed', 'hiding'],
win.document.querySelector('.query-completions').state);
});
});