mirror of
https://github.com/zotero/zotero.git
synced 2026-08-28 05:25:31 +00:00
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
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.
2151 lines
79 KiB
JavaScript
2151 lines
79 KiB
JavaScript
"use strict";
|
|
|
|
describe("Advanced Search", function () {
|
|
var win, zp, deck;
|
|
|
|
before(function* () {
|
|
yield resetDB({
|
|
thisArg: this,
|
|
skipBundledFiles: true
|
|
});
|
|
win = yield loadZoteroPane();
|
|
zp = win.ZoteroPane;
|
|
deck = win.document.getElementById('zotero-advanced-search-pane-deck');
|
|
});
|
|
|
|
after(function () {
|
|
win.close();
|
|
});
|
|
|
|
it("should perform a search", async function () {
|
|
var item = await createDataObject('item', { setTitle: true });
|
|
var otherItem = await createDataObject('item', { setTitle: true });
|
|
|
|
await zp.toggleAdvancedSearchState('open');
|
|
var pane = deck.pane;
|
|
|
|
// Opening the pane shouldn't filter the items list
|
|
await zp.itemsView.waitForLoad();
|
|
assert.equal(zp.itemsView.rowCount, 2);
|
|
|
|
// Add condition
|
|
var s = new Zotero.Search();
|
|
s.libraryID = item.libraryID;
|
|
s.addCondition('title', 'is', item.getField('title'));
|
|
pane.search = s;
|
|
|
|
// Run search and wait for results
|
|
var iv = zp.itemsView;
|
|
await pane.submit();
|
|
await iv.waitForLoad();
|
|
|
|
// Check results
|
|
assert.equal(iv.rowCount, 1);
|
|
var index = iv.getRowIndexByID(item.id);
|
|
assert.isNumber(index);
|
|
|
|
// Closing should restore the unfiltered view
|
|
await zp.setAdvancedSearchState('closed');
|
|
assert.equal(iv.rowCount, 2);
|
|
|
|
await item.eraseTx();
|
|
await otherItem.eraseTx();
|
|
});
|
|
|
|
it("shouldn't invalidate the collection tree row when clearing an unset advanced search", async function () {
|
|
// setAdvancedSearch() returns whether the row's filter changed, which
|
|
// setFilter() uses to decide whether to refresh the items list
|
|
let row = zp.getCollectionTreeRows()[0];
|
|
// Nothing to clear
|
|
assert.isFalse(row.setAdvancedSearch(null));
|
|
|
|
let s = new Zotero.Search();
|
|
s.libraryID = Zotero.Libraries.userLibraryID;
|
|
s.addCondition('title', 'is', 'foo');
|
|
// Applying and clearing a search are changes; clearing again isn't
|
|
assert.isTrue(row.setAdvancedSearch(s));
|
|
assert.isTrue(row.setAdvancedSearch(null));
|
|
assert.isFalse(row.setAdvancedSearch(null));
|
|
});
|
|
|
|
it("should seed conditions written in the quick search query", async function () {
|
|
var match = await createDataObject('item', { title: "alpha beta" });
|
|
match.addTag('zztag');
|
|
await match.saveTx();
|
|
var wrongTag = await createDataObject('item', { title: "alpha beta" });
|
|
await wrongTag.saveTx();
|
|
|
|
await zp.openAdvancedSearchFromQuickSearch('tag:zztag alpha', 'fields');
|
|
var iv = zp.itemsView;
|
|
await iv.waitForLoad();
|
|
|
|
var conditions = Object.values(deck.pane.search.getConditions());
|
|
var tag = conditions.find(c => c.condition === 'tag');
|
|
assert.isDefined(tag);
|
|
assert.equal(tag.value, 'zztag');
|
|
assert.equal(tag.operator, 'is');
|
|
// The rest of the query is still words to match
|
|
assert.sameMembers(
|
|
conditions.filter(c => c.condition === 'anyField').map(c => c.value),
|
|
['alpha']
|
|
);
|
|
|
|
assert.equal(iv.rowCount, 1);
|
|
assert.isNumber(iv.getRowIndexByID(match.id));
|
|
|
|
await zp.setAdvancedSearchState('closed');
|
|
await iv.waitForLoad();
|
|
await Zotero.Items.erase([match.id, wrongTag.id]);
|
|
});
|
|
|
|
it("should seed from the quick search text and reset on close", async function () {
|
|
var match = await createDataObject('item', { title: "alpha beta" });
|
|
var partial = await createDataObject('item', { title: "alpha gamma" });
|
|
var neither = await createDataObject('item', { title: "delta" });
|
|
|
|
var searchBox = win.document.getElementById('zotero-tb-search');
|
|
searchBox.value = 'alpha beta';
|
|
await zp.itemsView.setFilter('search', 'alpha beta');
|
|
|
|
await zp.openAdvancedSearchFromQuickSearch('alpha beta', 'fields');
|
|
var iv = zp.itemsView;
|
|
await iv.waitForLoad();
|
|
|
|
// One "Any Field" condition per word, joined with "all"
|
|
var conditions = Object.values(deck.pane.search.getConditions())
|
|
.filter(c => c.condition === 'anyField');
|
|
assert.lengthOf(conditions, 2);
|
|
assert.sameMembers(conditions.map(c => c.value), ['alpha', 'beta']);
|
|
|
|
// Only the item matching both words, and the quick search field is cleared
|
|
assert.equal(iv.rowCount, 1);
|
|
assert.isNumber(iv.getRowIndexByID(match.id));
|
|
assert.equal(searchBox.value, '');
|
|
|
|
// Closing resets to an unfiltered view (doesn't restore the quick search)
|
|
await zp.setAdvancedSearchState('closed');
|
|
await iv.waitForLoad();
|
|
assert.equal(iv.rowCount, 3);
|
|
|
|
await Zotero.Items.erase([match.id, partial.id, neither.id]);
|
|
});
|
|
|
|
it("should seed an \"Everything\" quick search as full-text groups", async function () {
|
|
await zp.openAdvancedSearchFromQuickSearch('alpha beta', 'everything');
|
|
await zp.itemsView.waitForLoad();
|
|
|
|
// Each word -> an "any" group of [Any Field, Full Text Content]
|
|
var conds = Object.values(deck.pane.search.getConditions());
|
|
assert.lengthOf(conds.filter(c => c.condition === 'groupStart'), 2);
|
|
assert.lengthOf(conds.filter(c => c.condition === 'anyField'), 2);
|
|
assert.sameMembers(
|
|
conds.filter(c => c.condition === 'fulltextContent').map(c => c.value),
|
|
['alpha', 'beta']
|
|
);
|
|
|
|
await zp.setAdvancedSearchState('closed');
|
|
});
|
|
it("should prefill a Title/Creator/Year quick search, restricted to top-level items", async function () {
|
|
// A top-level item whose title matches both words, and an item whose only match is a
|
|
// child attachment's title (which shouldn't broaden a top-level-only search)
|
|
var topMatch = await createDataObject('item', { title: "alpha beta" });
|
|
var childOnly = await createDataObject('item', { title: "zzznomatch" });
|
|
var attachment = await importPDFAttachment(childOnly);
|
|
attachment.setField('title', 'alpha beta');
|
|
await attachment.saveTx();
|
|
|
|
await zp.openAdvancedSearchFromQuickSearch('alpha beta', 'titleCreatorYear');
|
|
var iv = zp.itemsView;
|
|
await iv.waitForLoad();
|
|
|
|
// One "Title, Creator, Year" condition per word, plus a result level of item
|
|
var conds = Object.values(deck.pane.search.getConditions());
|
|
var tcy = conds.filter(c => c.condition === 'titleCreatorYear');
|
|
assert.lengthOf(tcy, 2);
|
|
assert.sameMembers(tcy.map(c => c.value), ['alpha', 'beta']);
|
|
assert.isTrue(conds.some(c => c.condition === 'resultLevel' && c.operator === 'item'));
|
|
|
|
// The top-level title matches; the child attachment's matching title doesn't broaden it
|
|
assert.equal(iv.rowCount, 1);
|
|
assert.isNumber(iv.getRowIndexByID(topMatch.id));
|
|
|
|
await zp.setAdvancedSearchState('closed');
|
|
await topMatch.eraseTx();
|
|
await childOnly.eraseTx();
|
|
});
|
|
|
|
it("should run a cross-level search across a multi-collection selection", async function () {
|
|
var word = 'zmc' + Zotero.Utilities.randomString();
|
|
var makeMatch = async function (collection) {
|
|
var item = await createDataObject('item', { collections: [collection.id] });
|
|
var attachment = await importPDFAttachment(item);
|
|
await createAnnotation('highlight', attachment, { comment: 'foo ' + word + ' bar' });
|
|
return item;
|
|
};
|
|
var c1 = await createDataObject('collection');
|
|
var c2 = await createDataObject('collection');
|
|
var c3 = await createDataObject('collection');
|
|
var itemA = await makeMatch(c1);
|
|
var itemB = await makeMatch(c2);
|
|
var itemC = await makeMatch(c3); // in an unselected collection
|
|
|
|
// Select c1 and c2, leaving c3 out
|
|
var cv = zp.collectionsView;
|
|
await cv.selectByID("C" + c1.id);
|
|
await waitForItemsLoad(win);
|
|
cv.selection.toggleSelect(cv.getRowIndexByID("C" + c2.id));
|
|
await zp.onCollectionSelected();
|
|
await zp.itemsView.waitForLoad();
|
|
|
|
// Top-level items with a descendant annotation matching the comment
|
|
var s = new Zotero.Search();
|
|
s.libraryID = Zotero.Libraries.userLibraryID;
|
|
s.addCondition('resultLevel', 'item');
|
|
s.addCondition('annotationComment', 'contains', word);
|
|
|
|
var iv = zp.itemsView;
|
|
await iv.setFilter('advanced-search', s);
|
|
await iv.waitForLoad();
|
|
|
|
// The view merges getItems() across the selected rows (collectionViewItemTree),
|
|
// so the cross-level search runs scoped to each collection and the results union:
|
|
// both selected collections' matching items, but not the unselected one's
|
|
var rows = zp.getCollectionTreeRows();
|
|
var ids = new Set();
|
|
for (let arr of await Promise.all(rows.map(row => row.getItems()))) {
|
|
for (let item of arr) ids.add(item.id);
|
|
}
|
|
assert.sameMembers([...ids], [itemA.id, itemB.id]);
|
|
|
|
await iv.setFilter('advanced-search', null);
|
|
await selectLibrary(win);
|
|
await Zotero.Items.erase([itemA.id, itemB.id, itemC.id]);
|
|
await Zotero.Collections.erase([c1.id, c2.id, c3.id]);
|
|
});
|
|
|
|
|
|
it("shouldn't match an image embedded in a note", async function () {
|
|
var item = await createDataObject('item');
|
|
item.addTag('a');
|
|
await item.saveTx();
|
|
var note = new Zotero.Item('note');
|
|
note.setNote('foo');
|
|
note.parentItemID = item.id;
|
|
note.addTag('a');
|
|
await note.saveTx();
|
|
await createEmbeddedImage(note);
|
|
|
|
await zp.toggleAdvancedSearchState('open');
|
|
var pane = deck.pane;
|
|
|
|
var s = new Zotero.Search();
|
|
s.libraryID = item.libraryID;
|
|
s.addCondition('numTags', 'is', '0');
|
|
pane.search = s;
|
|
|
|
var iv = zp.itemsView;
|
|
await pane.submit();
|
|
await iv.waitForLoad();
|
|
|
|
// The item and its note are tagged, so the only untagged row under the item is
|
|
// the embedded image, which is hidden and shouldn't pull the item into the results
|
|
assert.isFalse(iv.getRowIndexByID(item.id));
|
|
|
|
await zp.setAdvancedSearchState('closed');
|
|
});
|
|
|
|
it("shouldn't show trashed items outside the trash", async function () {
|
|
var item = await createDataObject('item', { setTitle: true });
|
|
item.deleted = true;
|
|
await item.saveTx();
|
|
|
|
await zp.toggleAdvancedSearchState('open');
|
|
var pane = deck.pane;
|
|
|
|
var s = new Zotero.Search();
|
|
s.libraryID = item.libraryID;
|
|
s.addCondition('title', 'is', item.getField('title'));
|
|
pane.search = s;
|
|
|
|
var iv = zp.itemsView;
|
|
await pane.submit();
|
|
await iv.waitForLoad();
|
|
|
|
assert.isFalse(iv.getRowIndexByID(item.id));
|
|
|
|
await zp.setAdvancedSearchState('closed');
|
|
|
|
await item.eraseTx();
|
|
});
|
|
|
|
it("should remove a trashed result and not show it again on re-run", async function () {
|
|
var item = await createDataObject('item', { setTitle: true });
|
|
|
|
await zp.toggleAdvancedSearchState('open');
|
|
var pane = deck.pane;
|
|
|
|
var s = new Zotero.Search();
|
|
s.libraryID = item.libraryID;
|
|
s.addCondition('title', 'is', item.getField('title'));
|
|
pane.search = s;
|
|
|
|
var iv = zp.itemsView;
|
|
await pane.submit();
|
|
await iv.waitForLoad();
|
|
assert.isNumber(iv.getRowIndexByID(item.id));
|
|
|
|
// Trashing the item should remove it from the results
|
|
await Zotero.Items.trashTx(item.id);
|
|
assert.isFalse(iv.getRowIndexByID(item.id));
|
|
|
|
// Re-running the search shouldn't bring it back
|
|
await pane.submit();
|
|
await iv.waitForLoad();
|
|
assert.isFalse(iv.getRowIndexByID(item.id));
|
|
|
|
await zp.setAdvancedSearchState('closed');
|
|
|
|
await item.eraseTx();
|
|
});
|
|
|
|
it("should show trashed items when in the trash", async function () {
|
|
var item = await createDataObject('item', { setTitle: true });
|
|
item.deleted = true;
|
|
await item.saveTx();
|
|
|
|
await selectTrash(win, item.libraryID);
|
|
|
|
await zp.toggleAdvancedSearchState('open');
|
|
var pane = deck.pane;
|
|
|
|
var s = new Zotero.Search();
|
|
s.libraryID = item.libraryID;
|
|
s.addCondition('title', 'is', item.getField('title'));
|
|
pane.search = s;
|
|
|
|
var iv = zp.itemsView;
|
|
await pane.submit();
|
|
await iv.waitForLoad();
|
|
|
|
assert.isNumber(iv.getRowIndexByID(item.id));
|
|
|
|
await zp.setAdvancedSearchState('closed');
|
|
await selectLibrary(win, item.libraryID);
|
|
|
|
await item.eraseTx();
|
|
});
|
|
|
|
it("shouldn't reapply previous search when reopened while closing", async function () {
|
|
var item = await createDataObject('item', { setTitle: true });
|
|
|
|
await zp.toggleAdvancedSearchState('open');
|
|
var s = new Zotero.Search();
|
|
s.libraryID = item.libraryID;
|
|
s.addCondition('title', 'is', 'nomatch');
|
|
deck.pane.search = s;
|
|
await deck.pane.submit();
|
|
await zp.itemsView.waitForLoad();
|
|
assert.equal(zp.itemsView.rowCount, 0);
|
|
|
|
// Close and immediately reopen, without waiting, as with UI clicks
|
|
zp.toggleAdvancedSearchState('closed');
|
|
await zp.toggleAdvancedSearchState('open');
|
|
await zp.itemsView.waitForLoad();
|
|
|
|
// The previous search shouldn't have been reapplied
|
|
assert.isNumber(zp.itemsView.getRowIndexByID(item.id));
|
|
|
|
await zp.setAdvancedSearchState('closed');
|
|
await item.eraseTx();
|
|
});
|
|
|
|
it("should scope results to the selected saved search", async function () {
|
|
var inBoth = await createDataObject('item', { title: "foo bar" });
|
|
var inSavedOnly = await createDataObject('item', { title: "foo baz" });
|
|
var inAdvancedOnly = await createDataObject('item', { title: "bar qux" });
|
|
|
|
var saved = new Zotero.Search();
|
|
saved.libraryID = Zotero.Libraries.userLibraryID;
|
|
saved.name = "Scope Test";
|
|
saved.addCondition('title', 'contains', 'foo');
|
|
await saved.saveTx();
|
|
await select(win, saved);
|
|
|
|
await zp.toggleAdvancedSearchState('open');
|
|
var s = new Zotero.Search();
|
|
s.libraryID = saved.libraryID;
|
|
s.addCondition('title', 'contains', 'bar');
|
|
deck.pane.search = s;
|
|
|
|
var iv = zp.itemsView;
|
|
await deck.pane.submit();
|
|
await iv.waitForLoad();
|
|
|
|
// Only the item matching both the saved search and the advanced search
|
|
assert.equal(iv.rowCount, 1);
|
|
assert.isNumber(iv.getRowIndexByID(inBoth.id));
|
|
|
|
// The saved search itself shouldn't have been modified
|
|
assert.lengthOf(Object.keys(saved.getConditions()), 1);
|
|
|
|
await zp.setAdvancedSearchState('closed');
|
|
|
|
await Zotero.Items.erase([inBoth.id, inSavedOnly.id, inAdvancedOnly.id]);
|
|
await saved.eraseTx();
|
|
});
|
|
|
|
it("should scope results to multiple selected collections", async function () {
|
|
var collection1 = await createDataObject('collection');
|
|
var collection2 = await createDataObject('collection');
|
|
var inFirst = await createDataObject('item', { title: "foo bar", collections: [collection1.id] });
|
|
var inSecond = await createDataObject('item', { title: "foo baz", collections: [collection2.id] });
|
|
var noMatch = await createDataObject('item', { title: "qux", collections: [collection2.id] });
|
|
var notInCollections = await createDataObject('item', { title: "foo qux" });
|
|
|
|
var cv = zp.collectionsView;
|
|
await cv.selectByID("C" + collection1.id);
|
|
await waitForItemsLoad(win);
|
|
cv.selection.toggleSelect(cv.getRowIndexByID("C" + collection2.id));
|
|
await zp.onCollectionSelected();
|
|
await zp.itemsView.waitForLoad();
|
|
|
|
await zp.toggleAdvancedSearchState('open');
|
|
var s = new Zotero.Search();
|
|
s.libraryID = Zotero.Libraries.userLibraryID;
|
|
s.addCondition('title', 'contains', 'foo');
|
|
deck.pane.search = s;
|
|
|
|
var iv = zp.itemsView;
|
|
await deck.pane.submit();
|
|
await iv.waitForLoad();
|
|
|
|
// Matching items from both collections, but not the non-matching item or the
|
|
// matching item outside the selected collections (the row count also includes the
|
|
// "2 collections selected" section header)
|
|
assert.isNumber(iv.getRowIndexByID(inFirst.id));
|
|
assert.isNumber(iv.getRowIndexByID(inSecond.id));
|
|
assert.isFalse(iv.getRowIndexByID(noMatch.id));
|
|
assert.isFalse(iv.getRowIndexByID(notInCollections.id));
|
|
|
|
await zp.setAdvancedSearchState('closed');
|
|
await selectLibrary(win);
|
|
|
|
await Zotero.Items.erase([inFirst.id, inSecond.id, noMatch.id, notInCollections.id]);
|
|
await Zotero.Collections.erase([collection1.id, collection2.id]);
|
|
});
|
|
|
|
it("should search across feeds in Feeds view", async function () {
|
|
let feed = await createFeed();
|
|
let feedItem = await createDataObject('feedItem', { libraryID: feed.libraryID, setTitle: true }, { skipSelect: true });
|
|
let otherFeedItem = await createDataObject('feedItem', { libraryID: feed.libraryID, setTitle: true }, { skipSelect: true });
|
|
|
|
await zp.collectionsView.selectFeeds();
|
|
await waitForItemsLoad(win);
|
|
|
|
await zp.toggleAdvancedSearchState('open');
|
|
var s = new Zotero.Search();
|
|
s.libraryID = Zotero.Libraries.userLibraryID;
|
|
s.addCondition('title', 'is', feedItem.getField('title'));
|
|
deck.pane.search = s;
|
|
|
|
var iv = zp.itemsView;
|
|
await deck.pane.submit();
|
|
await iv.waitForLoad();
|
|
|
|
assert.equal(iv.rowCount, 1);
|
|
assert.isNumber(iv.getRowIndexByID(feedItem.id));
|
|
|
|
await zp.setAdvancedSearchState('closed');
|
|
await selectLibrary(win);
|
|
|
|
await feed.eraseTx();
|
|
});
|
|
|
|
// Run the 'zotero' autocomplete provider directly with given params
|
|
function autocomplete(searchString, params) {
|
|
return new Promise((resolve) => {
|
|
let search = Cc["@mozilla.org/autocomplete/search;1?name=zotero"]
|
|
.createInstance(Ci.nsIAutoCompleteSearch);
|
|
let listener = {
|
|
onSearchResult(_search, result) {
|
|
// Ignore intermediate (ongoing) updates
|
|
if (result.searchResult == Ci.nsIAutoCompleteResult.RESULT_SUCCESS_ONGOING) {
|
|
return;
|
|
}
|
|
let values = [];
|
|
for (let i = 0; i < result.matchCount; i++) {
|
|
values.push(result.getValueAt(i));
|
|
}
|
|
resolve(values);
|
|
}
|
|
};
|
|
search.startSearch(searchString, JSON.stringify(params), null, listener);
|
|
});
|
|
}
|
|
|
|
it("should scope value autocomplete to the given libraries", async function () {
|
|
var group = await getGroup();
|
|
var groupLibraryID = group.libraryID;
|
|
// Autocomplete matches a value prefix, so both values start with the token
|
|
var token = Zotero.Utilities.randomString() + ' ';
|
|
var userPublisher = token + 'User';
|
|
var groupPublisher = token + 'Group';
|
|
var userItem = await createDataObject('item', { itemType: 'book' });
|
|
userItem.setField('publisher', userPublisher);
|
|
await userItem.saveTx();
|
|
var groupItem = await createDataObject('item', { itemType: 'book', libraryID: groupLibraryID });
|
|
groupItem.setField('publisher', groupPublisher);
|
|
await groupItem.saveTx();
|
|
|
|
// Scoped to the user library: only its value
|
|
var userOnly = await autocomplete(token, { fieldName: 'publisher', libraryIDs: [Zotero.Libraries.userLibraryID] });
|
|
assert.deepEqual(userOnly, [userPublisher]);
|
|
|
|
// Scoped to both libraries: both values
|
|
var both = await autocomplete(token, {
|
|
fieldName: 'publisher',
|
|
libraryIDs: [Zotero.Libraries.userLibraryID, groupLibraryID]
|
|
});
|
|
assert.includeMembers(both, [userPublisher, groupPublisher]);
|
|
|
|
await Zotero.Items.erase([userItem.id, groupItem.id]);
|
|
});
|
|
|
|
it("should not autocomplete a two-field creator without a first name as a leading space", async function () {
|
|
var item = createUnsavedDataObject('item');
|
|
item.setCreators([{ firstName: '', lastName: 'Solodova', creatorType: 'author' }]);
|
|
await item.saveTx();
|
|
|
|
var values = await autocomplete('solodova', { fieldName: 'creator', fieldMode: 2 });
|
|
assert.deepEqual(values, ['Solodova']);
|
|
|
|
await item.eraseTx();
|
|
});
|
|
|
|
it("should save a search in an editable group library root but not the trash", async function () {
|
|
var group = await getGroup();
|
|
var groupLibraryID = group.libraryID;
|
|
|
|
await selectLibrary(win, groupLibraryID);
|
|
await zp.toggleAdvancedSearchState('open');
|
|
var pane = deck.pane;
|
|
|
|
// Saving should be enabled at an editable group library root
|
|
assert.isFalse(pane._saveButton.disabled);
|
|
|
|
var s = new Zotero.Search();
|
|
s.libraryID = groupLibraryID;
|
|
s.addCondition('title', 'contains', 'foo');
|
|
pane.search = s;
|
|
// Saving prompts for a name
|
|
var promptService = Services.prompt;
|
|
Services.prompt = {
|
|
prompt: (parent, title, message, nameObj) => {
|
|
nameObj.value = 'Group Search';
|
|
return true;
|
|
}
|
|
};
|
|
try {
|
|
await pane.save();
|
|
}
|
|
finally {
|
|
Services.prompt = promptService;
|
|
}
|
|
|
|
// The search should have been saved to the group library
|
|
var searches = await Zotero.Searches.getAll(groupLibraryID);
|
|
assert.lengthOf(searches, 1);
|
|
var conditions = Object.values(searches[0].getConditions());
|
|
assert.lengthOf(conditions, 1);
|
|
assert.equal(conditions[0].condition, 'title');
|
|
assert.equal(conditions[0].value, 'foo');
|
|
|
|
// Saving should be disabled in the trash, which can't be scoped to
|
|
await selectTrash(win, groupLibraryID);
|
|
await zp.toggleAdvancedSearchState('open');
|
|
assert.isTrue(deck.pane._saveButton.disabled);
|
|
|
|
await zp.setAdvancedSearchState('closed');
|
|
await Zotero.Searches.erase(searches.map(s => s.id));
|
|
await selectLibrary(win);
|
|
});
|
|
|
|
it("should scope a saved search to the selected collection", async function () {
|
|
var collection = await createDataObject('collection');
|
|
var inCollection = await createDataObject('item', { title: "scoped foo", collections: [collection.id] });
|
|
var noMatch = await createDataObject('item', { title: "bar", collections: [collection.id] });
|
|
var outsideCollection = await createDataObject('item', { title: "scoped foo" });
|
|
|
|
await selectCollection(win, collection.id);
|
|
await zp.toggleAdvancedSearchState('open');
|
|
var pane = deck.pane;
|
|
assert.isFalse(pane._saveButton.disabled);
|
|
|
|
var s = new Zotero.Search();
|
|
s.libraryID = Zotero.Libraries.userLibraryID;
|
|
s.addCondition('title', 'contains', 'scoped');
|
|
pane.search = s;
|
|
|
|
var promptService = Services.prompt;
|
|
Services.prompt = {
|
|
prompt: (parent, title, message, nameObj) => {
|
|
nameObj.value = 'Collection Scoped';
|
|
return true;
|
|
}
|
|
};
|
|
try {
|
|
await pane.save();
|
|
}
|
|
finally {
|
|
Services.prompt = promptService;
|
|
}
|
|
|
|
var saved = (await Zotero.Searches.getAll(Zotero.Libraries.userLibraryID))
|
|
.find(x => x.name == 'Collection Scoped');
|
|
assert.ok(saved);
|
|
// The collection scope is a top-level condition above the existing one
|
|
var conditions = Object.values(saved.getConditions());
|
|
assert.equal(conditions[0].condition, 'collection');
|
|
assert.equal(conditions[0].value, collection.key);
|
|
// Only the matching item within the collection
|
|
var ids = await saved.search();
|
|
assert.sameMembers(ids, [inCollection.id]);
|
|
|
|
await saved.eraseTx();
|
|
await Zotero.Items.erase([inCollection.id, noMatch.id, outsideCollection.id]);
|
|
await collection.eraseTx();
|
|
await selectLibrary(win);
|
|
});
|
|
|
|
it("should group the scope and the existing conditions when saving an 'any' search with a collection and a saved search selected", async function () {
|
|
var scopeSearch = new Zotero.Search();
|
|
scopeSearch.libraryID = Zotero.Libraries.userLibraryID;
|
|
scopeSearch.name = "Scope Search";
|
|
scopeSearch.addCondition('title', 'contains', 'blue');
|
|
await scopeSearch.saveTx();
|
|
var collection = await createDataObject('collection');
|
|
// Matches a condition and is in the collection
|
|
var inCollection = await createDataObject('item', { title: "red apple", collections: [collection.id] });
|
|
// Matches a condition and the scope search
|
|
var inScopeSearch = await createDataObject('item', { title: "blue sky" });
|
|
// Matches a condition but is in neither scope
|
|
var outsideScopes = await createDataObject('item', { title: "red car" });
|
|
// In the collection but matches no condition
|
|
var noMatch = await createDataObject('item', { title: "green grass", collections: [collection.id] });
|
|
|
|
var cv = zp.collectionsView;
|
|
await cv.selectByID("C" + collection.id);
|
|
await waitForItemsLoad(win);
|
|
cv.selection.toggleSelect(cv.getRowIndexByID("S" + scopeSearch.id));
|
|
await zp.onCollectionSelected();
|
|
await zp.itemsView.waitForLoad();
|
|
|
|
await zp.toggleAdvancedSearchState('open');
|
|
var pane = deck.pane;
|
|
var s = new Zotero.Search();
|
|
s.libraryID = Zotero.Libraries.userLibraryID;
|
|
s.addCondition('joinMode', 'any');
|
|
s.addCondition('title', 'contains', 'red');
|
|
s.addCondition('title', 'contains', 'sky');
|
|
pane.search = s;
|
|
|
|
var promptService = Services.prompt;
|
|
Services.prompt = {
|
|
prompt: (parent, title, message, nameObj) => {
|
|
nameObj.value = 'Scoped Any Search';
|
|
return true;
|
|
}
|
|
};
|
|
try {
|
|
await pane.save();
|
|
}
|
|
finally {
|
|
Services.prompt = promptService;
|
|
}
|
|
|
|
var saved = (await Zotero.Searches.getAll(Zotero.Libraries.userLibraryID))
|
|
.find(x => x.name == 'Scoped Any Search');
|
|
assert.ok(saved);
|
|
// An 'any' group of the two scopes ANDed with an 'any' group of the conditions
|
|
var conditions = Object.values(saved.getConditions());
|
|
assert.lengthOf(conditions.filter(c => c.condition == 'groupStart'), 2);
|
|
assert.isTrue(conditions.some(c => c.condition == 'collection' && c.value == collection.key));
|
|
assert.isTrue(conditions.some(c => c.condition == 'savedSearch' && c.value == scopeSearch.key));
|
|
var ids = await saved.search();
|
|
assert.sameMembers(ids, [inCollection.id, inScopeSearch.id]);
|
|
|
|
await saved.eraseTx();
|
|
await Zotero.Items.erase([inCollection.id, inScopeSearch.id, outsideScopes.id, noMatch.id]);
|
|
await collection.eraseTx();
|
|
await scopeSearch.eraseTx();
|
|
await selectLibrary(win);
|
|
});
|
|
|
|
it("should close the saved-search editor when the edited search is deleted", async function () {
|
|
var saved = await createDataObject('search', { name: "DeleteWhileEditing" });
|
|
await select(win, saved);
|
|
await zp.setSavedSearchEditorState('open');
|
|
assert.equal(deck.state, 'open');
|
|
assert.equal(deck.selectedSearchType, 'saved');
|
|
|
|
// Deleting the search being edited should close the editor without a
|
|
// save-changes prompt (which would otherwise block here)
|
|
await saved.eraseTx();
|
|
for (let i = 0; i < 60 && deck.state !== 'closed'; i++) {
|
|
await Zotero.Promise.delay(50);
|
|
}
|
|
assert.equal(deck.state, 'closed');
|
|
|
|
await selectLibrary(win);
|
|
});
|
|
|
|
it("should close the saved-search editor without prompting when the edited search is trashed", async function () {
|
|
var saved = await createDataObject('search', { name: "TrashWhileEditing" });
|
|
await select(win, saved);
|
|
await zp.setSavedSearchEditorState('open');
|
|
assert.equal(deck.state, 'open');
|
|
assert.equal(deck.selectedSearchType, 'saved');
|
|
|
|
// Trashing the search being edited should close the editor without a
|
|
// save-changes prompt, as when it's deleted outright
|
|
let stub = sinon.stub().returns(1);
|
|
let promptService = win.Services.prompt;
|
|
win.Services.prompt = { confirmEx: stub };
|
|
try {
|
|
saved.deleted = true;
|
|
await saved.saveTx();
|
|
for (let i = 0; i < 60 && deck.state !== 'closed'; i++) {
|
|
await Zotero.Promise.delay(50);
|
|
}
|
|
assert.equal(deck.state, 'closed');
|
|
assert.equal(stub.callCount, 0);
|
|
}
|
|
finally {
|
|
win.Services.prompt = promptService;
|
|
}
|
|
|
|
await saved.eraseTx();
|
|
await selectLibrary(win);
|
|
});
|
|
|
|
it("should close the saved-search editor without prompting when nothing was changed", async function () {
|
|
var saved = await createDataObject('search', { name: "UnchangedEditing" });
|
|
await select(win, saved);
|
|
await zp.setSavedSearchEditorState('open');
|
|
assert.equal(deck.selectedSearchType, 'saved');
|
|
|
|
let stub = sinon.stub().returns(1); // Cancel
|
|
let promptService = win.Services.prompt;
|
|
win.Services.prompt = { confirmEx: stub };
|
|
try {
|
|
await selectLibrary(win);
|
|
assert.equal(stub.callCount, 0);
|
|
assert.equal(deck.state, 'closed');
|
|
}
|
|
finally {
|
|
win.Services.prompt = promptService;
|
|
}
|
|
|
|
await saved.eraseTx();
|
|
});
|
|
|
|
it("should prompt when only the saved search's name was changed", async function () {
|
|
var saved = await createDataObject('search', { name: "RenameWhileEditing" });
|
|
await select(win, saved);
|
|
await zp.setSavedSearchEditorState('open');
|
|
|
|
deck.pane.querySelector('#saved-search-name').value = "RenameWhileEditing 2";
|
|
|
|
let stub = sinon.stub().returns(0); // Save
|
|
let promptService = win.Services.prompt;
|
|
win.Services.prompt = { confirmEx: stub };
|
|
try {
|
|
await selectLibrary(win);
|
|
assert.equal(stub.callCount, 1);
|
|
assert.equal(saved.name, "RenameWhileEditing 2");
|
|
}
|
|
finally {
|
|
win.Services.prompt = promptService;
|
|
}
|
|
|
|
await saved.eraseTx();
|
|
});
|
|
|
|
it("should revert to the edited search without re-prompting when canceling", async function () {
|
|
var search1 = await createDataObject('search', { name: "CancelEditing1" });
|
|
var search2 = await createDataObject('search', { name: "CancelEditing2" });
|
|
let cv = zp.collectionsView;
|
|
|
|
await select(win, search1);
|
|
await zp.setSavedSearchEditorState('open');
|
|
assert.equal(deck.selectedSearchType, 'saved');
|
|
|
|
// Edit the editor's working copy, so that closing it prompts
|
|
var searchBox = deck.pane.querySelector('zoterosearch');
|
|
searchBox.querySelector('.conditions').firstChild.querySelector('#valuefield').value = 'edited';
|
|
searchBox.updateSearch();
|
|
|
|
// zoteroPane.js uses the pane window's Services, so stub there. Set it before
|
|
// touching the selection so no prompt can reach the real (modal) service.
|
|
let stub = sinon.stub().returns(1); // Cancel
|
|
let promptService = win.Services.prompt;
|
|
win.Services.prompt = { confirmEx: stub };
|
|
try {
|
|
// Form a [search1, search2] multi-selection without firing a selection change
|
|
cv.selection.selectEventsSuppressed = true;
|
|
cv.selection.select(cv.getRowIndexByID("S" + search1.id));
|
|
cv.selection.toggleSelect(cv.getRowIndexByID("S" + search2.id));
|
|
cv.selection.selectEventsSuppressed = false;
|
|
|
|
await zp.onCollectionSelected();
|
|
// Prompted once (no loop) and reverted to just the edited search
|
|
assert.equal(stub.callCount, 1);
|
|
assert.deepEqual(cv.getSelectedRows().map(r => r.id), ["S" + search1.id]);
|
|
}
|
|
finally {
|
|
win.Services.prompt = promptService;
|
|
}
|
|
|
|
await zp.setSavedSearchEditorState('closed');
|
|
await Zotero.Searches.erase([search1.id, search2.id]);
|
|
await selectLibrary(win);
|
|
});
|
|
|
|
it("should run a focused button's action on Enter instead of the pane default", async function () {
|
|
var saved = await createDataObject('search', { name: "EnterOnCancel" });
|
|
await select(win, saved);
|
|
await zp.setSavedSearchEditorState('open');
|
|
assert.equal(deck.selectedSearchType, 'saved');
|
|
|
|
var savedPane = deck.pane;
|
|
var save = sinon.stub(savedPane, 'save');
|
|
var cancel = sinon.stub(savedPane, 'cancel');
|
|
try {
|
|
var cancelButton = savedPane.querySelector('.cancel-button');
|
|
cancelButton.focus();
|
|
cancelButton.dispatchEvent(
|
|
new win.KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })
|
|
);
|
|
// Enter on the focused Cancel button cancels -- it mustn't save
|
|
assert.isTrue(save.notCalled);
|
|
assert.isTrue(cancel.called);
|
|
}
|
|
finally {
|
|
save.restore();
|
|
cancel.restore();
|
|
}
|
|
|
|
await zp.setSavedSearchEditorState('closed');
|
|
await saved.eraseTx();
|
|
await selectLibrary(win);
|
|
});
|
|
|
|
it("should prompt before replacing the saved-search editor with a new advanced search", async function () {
|
|
var saved = await createDataObject('search', { name: "ToggleWhileEditing" });
|
|
await select(win, saved);
|
|
await zp.setSavedSearchEditorState('open');
|
|
|
|
// Edit the editor's working copy
|
|
var searchBox = deck.pane.querySelector('zoterosearch');
|
|
searchBox.querySelector('.conditions').firstChild.querySelector('#valuefield').value = 'edited';
|
|
searchBox.updateSearch();
|
|
|
|
let stub = sinon.stub().returns(1); // Cancel
|
|
let promptService = win.Services.prompt;
|
|
win.Services.prompt = { confirmEx: stub };
|
|
try {
|
|
// Cmd-Shift-F opens a new temporary search over the editor
|
|
await zp.toggleAdvancedSearchState('open');
|
|
|
|
// Cancel keeps the editor open with the edits intact
|
|
assert.equal(stub.callCount, 1);
|
|
assert.equal(deck.selectedSearchType, 'saved');
|
|
assert.equal(deck.state, 'open');
|
|
assert.include(
|
|
Object.values(deck.pane.search.getConditions()).map(c => c.value), 'edited');
|
|
|
|
// Save saves the edits and continues to the temporary pane
|
|
stub.returns(0);
|
|
await zp.toggleAdvancedSearchState('open');
|
|
assert.equal(stub.callCount, 2);
|
|
assert.equal(deck.selectedSearchType, 'temporary');
|
|
assert.equal(deck.state, 'open');
|
|
assert.include(Object.values(saved.getConditions()).map(c => c.value), 'edited');
|
|
}
|
|
finally {
|
|
win.Services.prompt = promptService;
|
|
}
|
|
|
|
await zp.setAdvancedSearchState('closed');
|
|
await saved.eraseTx();
|
|
await selectLibrary(win);
|
|
});
|
|
|
|
it("should prompt for a name when saving a new search", async function () {
|
|
await selectLibrary(win);
|
|
await zp.toggleAdvancedSearchState('open');
|
|
var s = new Zotero.Search();
|
|
s.libraryID = Zotero.Libraries.userLibraryID;
|
|
s.addCondition('title', 'contains', 'foo');
|
|
deck.pane.search = s;
|
|
|
|
var promptService = Services.prompt;
|
|
try {
|
|
// Cancelling the name prompt leaves the pane open and saves nothing
|
|
Services.prompt = { prompt: () => false };
|
|
await deck.pane.save();
|
|
assert.equal(deck.state, 'open');
|
|
|
|
// Accepting saves with the entered name and closes the pane
|
|
Services.prompt = {
|
|
prompt: (parent, title, message, nameObj) => {
|
|
nameObj.value = 'My Named Search';
|
|
return true;
|
|
}
|
|
};
|
|
await deck.pane.save();
|
|
}
|
|
finally {
|
|
Services.prompt = promptService;
|
|
}
|
|
|
|
var searches = await Zotero.Searches.getAll(Zotero.Libraries.userLibraryID);
|
|
var saved = searches.find(x => x.name === 'My Named Search');
|
|
assert.ok(saved);
|
|
assert.equal(deck.state, 'closed');
|
|
|
|
await saved.eraseTx();
|
|
});
|
|
|
|
describe("Conditions", function () {
|
|
var pane, searchBox, conditions;
|
|
|
|
before(async function () {
|
|
await zp.toggleAdvancedSearchState('open');
|
|
pane = deck.pane;
|
|
searchBox = pane.querySelector('zoterosearch');
|
|
conditions = searchBox.querySelector('.conditions');
|
|
});
|
|
|
|
after(async function () {
|
|
await zp.setAdvancedSearchState('closed');
|
|
});
|
|
|
|
it("should reset to a single empty condition when the last populated condition is removed", async function () {
|
|
var s = new Zotero.Search();
|
|
s.libraryID = Zotero.Libraries.userLibraryID;
|
|
s.addCondition('title', 'contains', 'foo');
|
|
pane.search = s;
|
|
|
|
assert.lengthOf(conditions.childNodes, 1);
|
|
var row = conditions.firstChild;
|
|
|
|
// With a value entered, the last condition can be removed
|
|
assert.isFalse(row.querySelector('#remove').hasAttribute('disabled'));
|
|
|
|
row.onRemoveClicked();
|
|
|
|
// It's replaced by a single empty default condition with the remove button disabled
|
|
assert.lengthOf(conditions.childNodes, 1);
|
|
var newRow = conditions.firstChild;
|
|
assert.equal(newRow.selectedCondition, 'title');
|
|
assert.equal(newRow.querySelector('#valuefield').value, '');
|
|
assert.isTrue(newRow.querySelector('#remove').hasAttribute('disabled'));
|
|
|
|
// Focus moves to the new condition's drop-down
|
|
assert.equal(win.document.activeElement, newRow.querySelector('#conditionsmenu'));
|
|
});
|
|
it("should hide the value textbox for a menu-based condition", async function () {
|
|
var s = new Zotero.Search();
|
|
s.libraryID = Zotero.Libraries.userLibraryID;
|
|
s.addCondition('title', 'is', '');
|
|
pane.search = s;
|
|
|
|
var row = conditions.firstChild;
|
|
row.onConditionSelected('fileTypeID');
|
|
|
|
// File Type uses a value menu, so the textbox must actually be hidden, not just
|
|
// sitting alongside the menu
|
|
var valuefield = row.querySelector('#valuefield');
|
|
assert.isTrue(valuefield.hidden);
|
|
assert.equal(win.getComputedStyle(valuefield).display, 'none');
|
|
assert.isFalse(row.querySelector('#valuemenu').hidden);
|
|
});
|
|
|
|
it("should blank the value field for an isEmpty condition", async function () {
|
|
var s = new Zotero.Search();
|
|
s.libraryID = Zotero.Libraries.userLibraryID;
|
|
s.addCondition('place', 'isEmpty', '');
|
|
pane.search = s;
|
|
|
|
// The value field keeps its space in the row but must not be visible, and the
|
|
// other value controls are hidden
|
|
var row = conditions.firstChild;
|
|
var valuefield = row.querySelector('#valuefield');
|
|
assert.isFalse(valuefield.hidden);
|
|
assert.equal(win.getComputedStyle(valuefield).visibility, 'hidden');
|
|
assert.isTrue(row.querySelector('#valuemenu').hidden);
|
|
assert.isTrue(row.querySelector('#value-date-age').hidden);
|
|
|
|
// The row serializes with no value
|
|
var data = row.getConditionData();
|
|
assert.equal(data.condition, 'place');
|
|
assert.equal(data.operator, 'isEmpty');
|
|
assert.equal(data.value, '');
|
|
});
|
|
|
|
it("should keep a loaded annotationAuthor value while its author menu is populating", async function () {
|
|
var deferred = Zotero.Promise.defer();
|
|
var stub = sinon.stub(Zotero.Annotations, 'getAllAuthors').returns(deferred.promise);
|
|
try {
|
|
var s = new Zotero.Search();
|
|
s.libraryID = Zotero.Libraries.userLibraryID;
|
|
s.addCondition('annotationAuthor', 'is', '12345');
|
|
pane.search = s;
|
|
|
|
// Serializing while the author list is still loading shouldn't drop the value
|
|
var row = conditions.firstChild;
|
|
assert.equal(row.getConditionData().value, '12345');
|
|
|
|
deferred.resolve([{ name: "Some User", userID: 12345 }]);
|
|
// Let onConditionSelected() finish populating the menu
|
|
await Zotero.Promise.delay(0);
|
|
assert.isFalse(row.querySelector('#valuemenu').hidden);
|
|
assert.equal(row.getConditionData().value, '12345');
|
|
}
|
|
finally {
|
|
stub.restore();
|
|
}
|
|
});
|
|
|
|
it("should place attachment and annotation conditions in their submenus", function () {
|
|
var s = new Zotero.Search();
|
|
s.libraryID = Zotero.Libraries.userLibraryID;
|
|
s.addCondition('title', 'is', '');
|
|
pane.search = s;
|
|
|
|
var row = conditions.firstChild;
|
|
var attachmentMenu = row.querySelector('#attachment-conditions-menu');
|
|
var annotationMenu = row.querySelector('#annotation-conditions-menu');
|
|
|
|
// An attachment-level condition is in the Attachment submenu rather than at
|
|
// the top level of the conditions menu
|
|
assert.isFalse(row.isPrimaryCondition('fileTypeID'));
|
|
assert.ok(attachmentMenu.querySelector('menuitem[value="fileTypeID"]'));
|
|
|
|
// An annotation-level condition is in the Annotation submenu
|
|
assert.isFalse(row.isPrimaryCondition('annotationColor'));
|
|
assert.ok(annotationMenu.querySelector('menuitem[value="annotationColor"]'));
|
|
|
|
// Selecting one shows its full name as the closed-menu label
|
|
row.onConditionSelected('annotationColor');
|
|
assert.equal(row.selectedCondition, 'annotationColor');
|
|
assert.equal(
|
|
row.querySelector('#conditionsmenu').getAttribute('label'),
|
|
Zotero.SearchConditions.getLocalizedName('annotationColor')
|
|
);
|
|
});
|
|
|
|
it("should sort the submenus alphabetically among the top-level conditions", function () {
|
|
var s = new Zotero.Search();
|
|
s.libraryID = Zotero.Libraries.userLibraryID;
|
|
s.addCondition('title', 'is', '');
|
|
pane.search = s;
|
|
|
|
var row = conditions.firstChild;
|
|
var popup = row.querySelector('#conditionsmenu > menupopup');
|
|
|
|
// Top-level entries (including the Attachment/Annotation submenus) are in
|
|
// alphabetical order, with the catch-all "More" submenu kept last
|
|
var labels = [...popup.children]
|
|
.filter(node => node.id != 'more-conditions-menu')
|
|
.map(node => node.getAttribute('label'));
|
|
var collation = Zotero.getLocaleCollation();
|
|
var sorted = [...labels].sort((a, b) => collation.compareString(1, a, b));
|
|
assert.deepEqual(labels, sorted);
|
|
assert.equal(popup.lastElementChild.id, 'more-conditions-menu');
|
|
});
|
|
|
|
it("should keep an edited value when switching to another text condition", async function () {
|
|
var s = new Zotero.Search();
|
|
s.libraryID = Zotero.Libraries.userLibraryID;
|
|
s.addCondition('title', 'contains', '');
|
|
pane.search = s;
|
|
|
|
var row = conditions.firstChild;
|
|
// Simulate the user typing into the value field after the row was loaded
|
|
row.querySelector('#valuefield').value = 'foo';
|
|
|
|
row.onConditionSelected('publicationTitle');
|
|
|
|
// The typed value carries over rather than reverting to the stale loaded value
|
|
assert.equal(row.querySelector('#valuefield').value, 'foo');
|
|
assert.equal(row.getConditionData().value, 'foo');
|
|
});
|
|
|
|
|
|
describe("Find-as-you-type", function () {
|
|
function typeInMenu(menu, str) {
|
|
for (let ch of str) {
|
|
menu.dispatchEvent(new win.KeyboardEvent('keydown', {
|
|
key: ch,
|
|
bubbles: true,
|
|
cancelable: true
|
|
}));
|
|
}
|
|
}
|
|
|
|
it("should select a condition from the More submenu by typing its name", function () {
|
|
var s = new Zotero.Search();
|
|
s.libraryID = Zotero.Libraries.userLibraryID;
|
|
s.addCondition('title', 'is', '');
|
|
pane.search = s;
|
|
|
|
var searchCondition = conditions.firstChild;
|
|
var conditionsMenu = searchCondition.querySelector('#conditionsmenu');
|
|
|
|
// "Language" lives in the More submenu, so it's only reachable via type-ahead
|
|
assert.isFalse(searchCondition.isPrimaryCondition('language'));
|
|
typeInMenu(conditionsMenu, 'lang');
|
|
assert.equal(searchCondition.selectedCondition, 'language');
|
|
});
|
|
|
|
it("should cycle through matches when the same letter is typed repeatedly", function () {
|
|
var s = new Zotero.Search();
|
|
s.libraryID = Zotero.Libraries.userLibraryID;
|
|
s.addCondition('title', 'is', '');
|
|
pane.search = s;
|
|
|
|
var searchCondition = conditions.firstChild;
|
|
var conditionsMenu = searchCondition.querySelector('#conditionsmenu');
|
|
|
|
typeInMenu(conditionsMenu, 'd');
|
|
var first = searchCondition.selectedCondition;
|
|
typeInMenu(conditionsMenu, 'd');
|
|
var second = searchCondition.selectedCondition;
|
|
|
|
assert.notEqual(first, second);
|
|
assert.match(Zotero.SearchConditions.getLocalizedName(first), /^d/i);
|
|
assert.match(Zotero.SearchConditions.getLocalizedName(second), /^d/i);
|
|
});
|
|
});
|
|
|
|
describe("Keyboard", function () {
|
|
// Focus is deferred to after the originating key event is handled
|
|
function nextTick() {
|
|
return new Promise(resolve => win.setTimeout(resolve));
|
|
}
|
|
|
|
it("should focus the new row's condition drop-down when adding via the + button", async function () {
|
|
var s = new Zotero.Search();
|
|
s.libraryID = Zotero.Libraries.userLibraryID;
|
|
s.addCondition('title', 'is', '');
|
|
pane.search = s;
|
|
|
|
var before = conditions.childNodes.length;
|
|
// Native keyboard activation of the button dispatches a click with detail 0
|
|
conditions.firstChild.querySelector('#add').dispatchEvent(
|
|
new win.MouseEvent('click', { detail: 0, bubbles: true, cancelable: true })
|
|
);
|
|
|
|
assert.equal(conditions.childNodes.length, before + 1);
|
|
await nextTick();
|
|
assert.equal(
|
|
win.document.activeElement,
|
|
conditions.lastChild.querySelector('#conditionsmenu')
|
|
);
|
|
});
|
|
|
|
it("shouldn't move focus when adding via the + button with the mouse", function () {
|
|
var s = new Zotero.Search();
|
|
s.libraryID = Zotero.Libraries.userLibraryID;
|
|
s.addCondition('title', 'is', '');
|
|
pane.search = s;
|
|
|
|
var before = conditions.childNodes.length;
|
|
var addButton = conditions.firstChild.querySelector('#add');
|
|
addButton.focus();
|
|
// A mouse click has detail >= 1
|
|
addButton.dispatchEvent(
|
|
new win.MouseEvent('click', { detail: 1, bubbles: true, cancelable: true })
|
|
);
|
|
|
|
assert.equal(conditions.childNodes.length, before + 1);
|
|
assert.equal(win.document.activeElement, addButton);
|
|
});
|
|
|
|
it("should add a condition and focus its drop-down on Shift-Enter", async function () {
|
|
var s = new Zotero.Search();
|
|
s.libraryID = Zotero.Libraries.userLibraryID;
|
|
s.addCondition('title', 'is', '');
|
|
pane.search = s;
|
|
|
|
var before = conditions.childNodes.length;
|
|
conditions.firstChild.querySelector('#conditionsmenu').dispatchEvent(
|
|
new win.KeyboardEvent('keypress', {
|
|
key: 'Enter',
|
|
keyCode: 13,
|
|
shiftKey: true,
|
|
bubbles: true,
|
|
cancelable: true
|
|
})
|
|
);
|
|
|
|
assert.equal(conditions.childNodes.length, before + 1);
|
|
await nextTick();
|
|
assert.equal(
|
|
win.document.activeElement,
|
|
conditions.lastChild.querySelector('#conditionsmenu')
|
|
);
|
|
});
|
|
|
|
it("shouldn't run the search on Shift-Enter", function () {
|
|
var s = new Zotero.Search();
|
|
s.libraryID = Zotero.Libraries.userLibraryID;
|
|
s.addCondition('title', 'is', '');
|
|
pane.search = s;
|
|
|
|
function pressEnter(shiftKey) {
|
|
conditions.firstChild.querySelector('#conditionsmenu').dispatchEvent(
|
|
new win.KeyboardEvent('keydown', {
|
|
key: 'Enter',
|
|
shiftKey,
|
|
bubbles: true,
|
|
cancelable: true
|
|
})
|
|
);
|
|
}
|
|
|
|
var submit = sinon.stub(pane, 'submit');
|
|
try {
|
|
// Plain Enter runs the search
|
|
pressEnter(false);
|
|
assert.isTrue(submit.calledOnce);
|
|
// Shift-Enter does not
|
|
pressEnter(true);
|
|
assert.isTrue(submit.calledOnce);
|
|
}
|
|
finally {
|
|
submit.restore();
|
|
}
|
|
});
|
|
|
|
// Native keyboard activation of a button dispatches a click with detail 0
|
|
function clickFromKeyboard(button) {
|
|
button.dispatchEvent(
|
|
new win.MouseEvent('click', { detail: 0, bubbles: true, cancelable: true })
|
|
);
|
|
}
|
|
|
|
function threeConditionSearch() {
|
|
var s = new Zotero.Search();
|
|
s.libraryID = Zotero.Libraries.userLibraryID;
|
|
s.addCondition('title', 'contains', 'a');
|
|
s.addCondition('title', 'contains', 'b');
|
|
s.addCondition('title', 'contains', 'c');
|
|
pane.search = s;
|
|
}
|
|
|
|
it("should focus the next row's remove button when removing via the keyboard", async function () {
|
|
threeConditionSearch();
|
|
assert.equal(conditions.childNodes.length, 3);
|
|
|
|
// Remove the first row
|
|
clickFromKeyboard(conditions.childNodes[0].querySelector('#remove'));
|
|
|
|
assert.equal(conditions.childNodes.length, 2);
|
|
await nextTick();
|
|
// Focus moves to the row that took its place
|
|
assert.equal(
|
|
win.document.activeElement,
|
|
conditions.childNodes[0].querySelector('#remove')
|
|
);
|
|
});
|
|
|
|
it("should focus the previous row's remove button when removing the last row", async function () {
|
|
threeConditionSearch();
|
|
|
|
// Remove the last row
|
|
clickFromKeyboard(conditions.childNodes[2].querySelector('#remove'));
|
|
|
|
assert.equal(conditions.childNodes.length, 2);
|
|
await nextTick();
|
|
// Focus moves up to the new last row
|
|
assert.equal(
|
|
win.document.activeElement,
|
|
conditions.childNodes[1].querySelector('#remove')
|
|
);
|
|
});
|
|
|
|
it("should focus the row that took a pruned group's place when removing via the keyboard", async function () {
|
|
var s = new Zotero.Search();
|
|
s.libraryID = Zotero.Libraries.userLibraryID;
|
|
s.addCondition('title', 'contains', 'a');
|
|
s.addCondition('groupStart', 'true', '');
|
|
s.addCondition('tag', 'is', 'x');
|
|
s.addCondition('groupEnd', 'true', '');
|
|
s.addCondition('title', 'contains', 'b');
|
|
pane.search = s;
|
|
|
|
// Removing the group's only condition prunes the group itself
|
|
var group = conditions.querySelector('search-condition-group');
|
|
clickFromKeyboard(group.conditionsContainer.firstChild.querySelector('#remove'));
|
|
|
|
assert.notOk(conditions.querySelector('search-condition-group'));
|
|
assert.lengthOf(conditions.children, 2);
|
|
await nextTick();
|
|
// Focus lands on the row that took the group's place ('b'), not the first row
|
|
assert.equal(
|
|
win.document.activeElement,
|
|
conditions.children[1].querySelector('#remove')
|
|
);
|
|
});
|
|
});
|
|
|
|
it("should insert a condition right after the row whose + is clicked", function () {
|
|
var s = new Zotero.Search();
|
|
s.libraryID = Zotero.Libraries.userLibraryID;
|
|
s.addCondition('title', 'contains', 'a');
|
|
s.addCondition('title', 'contains', 'b');
|
|
pane.search = s;
|
|
assert.lengthOf(conditions.children, 2);
|
|
|
|
conditions.children[0].onAddClicked({ preventDefault() {} });
|
|
|
|
// New (empty) condition lands between the two, not at the end
|
|
var values = [...conditions.children]
|
|
.map(row => row.querySelector('#valuefield').value);
|
|
assert.deepEqual(values, ['a', '', 'b']);
|
|
});
|
|
|
|
describe("Collection", function () {
|
|
it("should show only collections", async function () {
|
|
var col1 = await createDataObject('collection', { name: "A" });
|
|
var col2 = await createDataObject('collection', { name: "C", parentID: col1.id });
|
|
var col3 = await createDataObject('collection', { name: "D", parentID: col2.id });
|
|
var col4 = await createDataObject('collection', { name: "B" });
|
|
var search1 = await createDataObject('search', { name: "A" });
|
|
var search2 = await createDataObject('search', { name: "B" });
|
|
|
|
// Add condition
|
|
var s = new Zotero.Search();
|
|
s.libraryID = Zotero.Libraries.userLibraryID;
|
|
s.addCondition('title', 'is', '');
|
|
pane.search = s;
|
|
|
|
var searchCondition = conditions.firstChild;
|
|
var conditionsMenu = searchCondition.querySelector('#conditionsmenu');
|
|
var valueMenu = searchCondition.querySelector('#valuemenu');
|
|
|
|
assert.isTrue(valueMenu.hidden);
|
|
// Select 'Collection' condition
|
|
for (let i = 0; i < conditionsMenu.itemCount; i++) {
|
|
let menuitem = conditionsMenu.getItemAtIndex(i);
|
|
if (menuitem.value == 'collection') {
|
|
menuitem.click();
|
|
break;
|
|
}
|
|
}
|
|
|
|
assert.isFalse(valueMenu.hidden);
|
|
// Only the collections, with the saved searches no longer mixed in
|
|
assert.equal(valueMenu.itemCount, 4);
|
|
// Subcollections are indented via a margin on the icon
|
|
function getIndent(menuitem) {
|
|
return win.getComputedStyle(menuitem.querySelector('.menu-icon'))
|
|
.marginInlineStart;
|
|
}
|
|
var valueMenuItem = valueMenu.getItemAtIndex(1);
|
|
assert.equal(valueMenuItem.getAttribute('label'), col2.name);
|
|
assert.equal(valueMenuItem.getAttribute('value'), "C" + col2.key);
|
|
assert.equal(getIndent(valueMenuItem), '16px');
|
|
valueMenuItem = valueMenu.getItemAtIndex(2);
|
|
assert.equal(valueMenuItem.getAttribute('label'), col3.name);
|
|
assert.equal(valueMenuItem.getAttribute('value'), "C" + col3.key);
|
|
assert.equal(getIndent(valueMenuItem), '32px');
|
|
var values = [];
|
|
for (let i = 0; i < valueMenu.itemCount; i++) {
|
|
values.push(valueMenu.getItemAtIndex(i).getAttribute('value'));
|
|
}
|
|
assert.notInclude(values, "S" + search1.key);
|
|
assert.notInclude(values, "S" + search2.key);
|
|
|
|
await Zotero.Collections.erase([col1.id, col2.id, col3.id, col4.id]);
|
|
await Zotero.Searches.erase([search1.id, search2.id]);
|
|
});
|
|
|
|
it("should update when the library is changed", async function () {
|
|
var group = await getGroup();
|
|
var groupLibraryID = group.libraryID;
|
|
|
|
var collection1 = await createDataObject('collection', { name: "A" });
|
|
var collection2 = await createDataObject('collection', { name: "C", libraryID: groupLibraryID });
|
|
|
|
var s = new Zotero.Search();
|
|
s.libraryID = Zotero.Libraries.userLibraryID;
|
|
s.addCondition('title', 'is', '');
|
|
pane.search = s;
|
|
|
|
var searchCondition = conditions.firstChild;
|
|
var conditionsMenu = searchCondition.querySelector('#conditionsmenu');
|
|
var valueMenu = searchCondition.querySelector('#valuemenu');
|
|
|
|
// Select 'Collection' condition
|
|
for (let i = 0; i < conditionsMenu.itemCount; i++) {
|
|
let menuitem = conditionsMenu.getItemAtIndex(i);
|
|
if (menuitem.value == 'collection') {
|
|
menuitem.click();
|
|
break;
|
|
}
|
|
}
|
|
for (let i = 0; i < valueMenu.itemCount; i++) {
|
|
let menuitem = valueMenu.getItemAtIndex(i);
|
|
if (menuitem.getAttribute('value') == "C" + collection1.key) {
|
|
menuitem.click();
|
|
break;
|
|
}
|
|
}
|
|
assert.equal(valueMenu.value, "C" + collection1.key);
|
|
|
|
// Switch to the group library in the collection tree, which changes
|
|
// the search library and re-renders the conditions
|
|
await selectLibrary(win, groupLibraryID);
|
|
|
|
var values = [];
|
|
searchCondition = conditions.firstChild;
|
|
valueMenu = searchCondition.querySelector('#valuemenu');
|
|
assert.equal(valueMenu.value, "C" + collection2.key);
|
|
for (let i = 0; i < valueMenu.itemCount; i++) {
|
|
let menuitem = valueMenu.getItemAtIndex(i);
|
|
values.push(menuitem.getAttribute('value'));
|
|
}
|
|
assert.notInclude(values, "C" + collection1.key);
|
|
assert.include(values, "C" + collection2.key);
|
|
|
|
await selectLibrary(win);
|
|
|
|
await Zotero.Collections.erase([collection1.id, collection2.id]);
|
|
});
|
|
|
|
it("shouldn't appear in a cross-library scope", async function () {
|
|
var group = await getGroup();
|
|
var groupLibraryID = group.libraryID;
|
|
|
|
// Simulate the cross-library scope that advancedSearchPane.refresh() sets
|
|
// from a multi-library collection selection
|
|
searchBox.scopeLibraryIDs = [Zotero.Libraries.userLibraryID, groupLibraryID];
|
|
try {
|
|
var s = new Zotero.Search();
|
|
s.libraryID = Zotero.Libraries.userLibraryID;
|
|
s.addCondition('title', 'is', '');
|
|
pane.search = s;
|
|
|
|
var searchCondition = conditions.firstChild;
|
|
var conditionsMenu = searchCondition.querySelector('#conditionsmenu');
|
|
|
|
// Neither Collection nor Saved Search can resolve across libraries
|
|
for (let i = 0; i < conditionsMenu.itemCount; i++) {
|
|
assert.notEqual(conditionsMenu.getItemAtIndex(i).value, 'collection');
|
|
assert.notEqual(conditionsMenu.getItemAtIndex(i).value, 'savedSearch');
|
|
}
|
|
}
|
|
finally {
|
|
searchBox.scopeLibraryIDs = null;
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("Saved Search", function () {
|
|
it("should appear as a condition", function () {
|
|
var s = new Zotero.Search();
|
|
s.libraryID = Zotero.Libraries.userLibraryID;
|
|
s.addCondition('title', 'is', '');
|
|
pane.search = s;
|
|
|
|
var searchCondition = conditions.firstChild;
|
|
var conditionsMenu = searchCondition.querySelector('#conditionsmenu');
|
|
|
|
var values = [];
|
|
for (let i = 0; i < conditionsMenu.itemCount; i++) {
|
|
values.push(conditionsMenu.getItemAtIndex(i).value);
|
|
}
|
|
assert.include(values, 'savedSearch');
|
|
});
|
|
|
|
it("should show only saved searches", async function () {
|
|
var collection = await createDataObject('collection', { name: "A" });
|
|
var search1 = await createDataObject('search', { name: "A" });
|
|
var search2 = await createDataObject('search', { name: "B" });
|
|
|
|
var s = new Zotero.Search();
|
|
s.libraryID = Zotero.Libraries.userLibraryID;
|
|
s.addCondition('title', 'is', '');
|
|
pane.search = s;
|
|
|
|
var searchCondition = conditions.firstChild;
|
|
var conditionsMenu = searchCondition.querySelector('#conditionsmenu');
|
|
var valueMenu = searchCondition.querySelector('#valuemenu');
|
|
|
|
assert.isTrue(valueMenu.hidden);
|
|
// Select 'Saved Search' condition
|
|
for (let i = 0; i < conditionsMenu.itemCount; i++) {
|
|
let menuitem = conditionsMenu.getItemAtIndex(i);
|
|
if (menuitem.value == 'savedSearch') {
|
|
menuitem.click();
|
|
break;
|
|
}
|
|
}
|
|
|
|
assert.isFalse(valueMenu.hidden);
|
|
var values = [];
|
|
for (let i = 0; i < valueMenu.itemCount; i++) {
|
|
values.push(valueMenu.getItemAtIndex(i).getAttribute('value'));
|
|
}
|
|
assert.sameMembers(values, ["S" + search1.key, "S" + search2.key]);
|
|
assert.notInclude(values, "C" + collection.key);
|
|
|
|
await collection.eraseTx();
|
|
await Zotero.Searches.erase([search1.id, search2.id]);
|
|
});
|
|
|
|
it("should be selected for a 'savedSearch' condition", async function () {
|
|
var search = await createDataObject('search', { name: "A" });
|
|
|
|
var s = new Zotero.Search();
|
|
s.libraryID = Zotero.Libraries.userLibraryID;
|
|
s.addCondition('savedSearch', 'is', search.key);
|
|
pane.search = s;
|
|
|
|
var searchCondition = conditions.firstChild;
|
|
var conditionsMenu = searchCondition.querySelector('#conditionsmenu');
|
|
var valueMenu = searchCondition.querySelector('#valuemenu');
|
|
|
|
assert.equal(conditionsMenu.selectedItem.value, 'savedSearch');
|
|
assert.isFalse(valueMenu.hidden);
|
|
assert.equal(valueMenu.selectedItem.value, "S" + search.key);
|
|
|
|
await search.eraseTx();
|
|
});
|
|
|
|
it("should set a 'savedSearch' condition when a search is selected", async function () {
|
|
var search = await createDataObject('search', { name: "B" });
|
|
|
|
var s = new Zotero.Search();
|
|
s.libraryID = Zotero.Libraries.userLibraryID;
|
|
s.addCondition('title', 'is', '');
|
|
pane.search = s;
|
|
|
|
var searchCondition = conditions.firstChild;
|
|
var conditionsMenu = searchCondition.querySelector('#conditionsmenu');
|
|
var valueMenu = searchCondition.querySelector('#valuemenu');
|
|
|
|
// Select 'Saved Search' condition
|
|
for (let i = 0; i < conditionsMenu.itemCount; i++) {
|
|
let menuitem = conditionsMenu.getItemAtIndex(i);
|
|
if (menuitem.value == 'savedSearch') {
|
|
menuitem.click();
|
|
break;
|
|
}
|
|
}
|
|
for (let i = 0; i < valueMenu.itemCount; i++) {
|
|
let menuitem = valueMenu.getItemAtIndex(i);
|
|
if (menuitem.getAttribute('value') == "S" + search.key) {
|
|
menuitem.click();
|
|
break;
|
|
}
|
|
}
|
|
|
|
searchBox.updateSearch();
|
|
var condition = searchBox.search.getConditions()[0];
|
|
assert.equal(condition.condition, 'savedSearch');
|
|
assert.equal(condition.value, search.key);
|
|
|
|
await search.eraseTx();
|
|
});
|
|
});
|
|
|
|
describe("Search subcollections", function () {
|
|
it("should appear only when there's a Collection condition", async function () {
|
|
var collection = await createDataObject('collection');
|
|
|
|
var s = new Zotero.Search();
|
|
s.libraryID = Zotero.Libraries.userLibraryID;
|
|
s.addCondition('title', 'is', '');
|
|
pane.search = s;
|
|
|
|
var options = searchBox.querySelector('#search-option-checkboxes');
|
|
// Hidden while the search has no Collection condition to recurse into
|
|
assert.isTrue(options.hidden);
|
|
|
|
// Switch the row to a Collection condition
|
|
var conditionsMenu = conditions.firstChild.querySelector('#conditionsmenu');
|
|
for (let i = 0; i < conditionsMenu.itemCount; i++) {
|
|
let menuitem = conditionsMenu.getItemAtIndex(i);
|
|
if (menuitem.value == 'collection') {
|
|
menuitem.click();
|
|
break;
|
|
}
|
|
}
|
|
|
|
assert.isFalse(options.hidden);
|
|
|
|
await collection.eraseTx();
|
|
});
|
|
});
|
|
|
|
describe("Groups", function () {
|
|
function groupedSearch() {
|
|
var s = new Zotero.Search();
|
|
s.libraryID = Zotero.Libraries.userLibraryID;
|
|
s.addCondition('title', 'contains', 'foo');
|
|
s.addCondition('groupStart', 'true', '');
|
|
s.addCondition('joinMode', 'any');
|
|
s.addCondition('tag', 'is', 'x');
|
|
s.addCondition('tag', 'is', 'y');
|
|
s.addCondition('groupEnd', 'true', '');
|
|
return s;
|
|
}
|
|
|
|
it("should render a nested group from group markers", function () {
|
|
pane.search = groupedSearch();
|
|
|
|
// Root holds the title row plus one nested group
|
|
assert.lengthOf(conditions.children, 2);
|
|
var group = conditions.querySelector('search-condition-group');
|
|
assert.equal(group.joinMode, 'any');
|
|
assert.lengthOf(group.conditionsContainer.children, 2);
|
|
assert.equal(group.conditionsContainer.firstChild.selectedCondition, 'tag');
|
|
});
|
|
|
|
it("should serialize a nested group back to markers", function () {
|
|
pane.search = groupedSearch();
|
|
searchBox.updateSearch();
|
|
|
|
var sequence = Object.values(searchBox.search.getConditions())
|
|
.map(c => c.condition);
|
|
assert.deepEqual(sequence,
|
|
['title', 'groupStart', 'joinMode', 'tag', 'tag', 'groupEnd']);
|
|
});
|
|
|
|
it("should keep a binding that scopes a condition matching at any level", async function () {
|
|
// An item whose attachment has the tag, and one where the tag is on a
|
|
// note instead
|
|
var hit = await createDataObject('item');
|
|
var attachment = await importFileAttachment('test.png', { parentID: hit.id });
|
|
attachment.setTags([{ tag: 'zbound' }]);
|
|
await attachment.saveTx();
|
|
var miss = await createDataObject('item');
|
|
var note = new Zotero.Item('note');
|
|
note.parentID = miss.id;
|
|
note.setNote('note');
|
|
note.setTags([{ tag: 'zbound' }]);
|
|
await note.saveTx();
|
|
|
|
var s = new Zotero.Search();
|
|
s.libraryID = Zotero.Libraries.userLibraryID;
|
|
s.addCondition('resultLevel', 'item');
|
|
s.addCondition('groupStart', 'true', '');
|
|
s.addCondition('resultLevel', 'attachment');
|
|
s.addCondition('tag', 'is', 'zbound');
|
|
s.addCondition('groupEnd', 'true', '');
|
|
pane.search = s;
|
|
searchBox.updateSearch();
|
|
|
|
var group = conditions.querySelector('search-condition-group');
|
|
assert.equal(group.resultLevel, 'attachment');
|
|
// The tag has to be on the attachment, not anywhere in the item
|
|
var ids = await searchBox.search.search();
|
|
assert.include(ids, hit.id);
|
|
assert.notInclude(ids, miss.id);
|
|
});
|
|
|
|
it("should drop a binding that the result level makes meaningless", function () {
|
|
var s = new Zotero.Search();
|
|
s.libraryID = Zotero.Libraries.userLibraryID;
|
|
s.addCondition('resultLevel', 'attachment');
|
|
s.addCondition('groupStart', 'true', '');
|
|
s.addCondition('resultLevel', 'attachment');
|
|
s.addCondition('tag', 'is', 'zbound');
|
|
s.addCondition('groupEnd', 'true', '');
|
|
pane.search = s;
|
|
searchBox.updateSearch();
|
|
|
|
// Results are attachments, so binding the group to the attachment says
|
|
// nothing the result level doesn't
|
|
var group = conditions.querySelector('search-condition-group');
|
|
assert.equal(group.resultLevel, 'any');
|
|
assert.deepEqual(
|
|
Object.values(searchBox.search.getConditions()).map(c => c.condition),
|
|
['resultLevel', 'groupStart', 'tag', 'groupEnd']
|
|
);
|
|
});
|
|
|
|
it("should wrap a condition in a new group in its place", function () {
|
|
var s = new Zotero.Search();
|
|
s.libraryID = Zotero.Libraries.userLibraryID;
|
|
s.addCondition('title', 'contains', 'foo');
|
|
pane.search = s;
|
|
|
|
conditions.firstChild.onGroupClicked({ preventDefault() {} });
|
|
|
|
// The condition is now the sole member of a group in its old place,
|
|
// keeping its value
|
|
assert.lengthOf(conditions.children, 1);
|
|
var group = conditions.querySelector('search-condition-group');
|
|
assert.lengthOf(group.conditionsContainer.children, 1);
|
|
var row = group.conditionsContainer.firstChild;
|
|
assert.equal(row.selectedCondition, 'title');
|
|
assert.equal(row.querySelector('#valuefield').value, 'foo');
|
|
});
|
|
|
|
it("should ungroup a group back into its parent", function () {
|
|
pane.search = groupedSearch();
|
|
|
|
var group = conditions.querySelector('search-condition-group');
|
|
group.onUngroupClicked();
|
|
|
|
// The group is gone and its two tag conditions now sit alongside the title row
|
|
assert.notOk(conditions.querySelector('search-condition-group'));
|
|
searchBox.updateSearch();
|
|
var sequence = Object.values(searchBox.search.getConditions())
|
|
.map(c => c.condition);
|
|
assert.deepEqual(sequence, ['title', 'tag', 'tag']);
|
|
});
|
|
|
|
it("should remove a group when its last condition is removed", function () {
|
|
pane.search = groupedSearch();
|
|
|
|
var group = conditions.querySelector('search-condition-group');
|
|
group.conditionsContainer.firstChild.onRemoveClicked();
|
|
// Removing the group's first tag leaves it with the second
|
|
assert.ok(conditions.querySelector('search-condition-group'));
|
|
group.conditionsContainer.firstChild.onRemoveClicked();
|
|
|
|
// With no conditions left, the group itself is gone
|
|
assert.notOk(conditions.querySelector('search-condition-group'));
|
|
// The root's title condition remains
|
|
assert.lengthOf(conditions.children, 1);
|
|
assert.equal(conditions.firstChild.selectedCondition, 'title');
|
|
});
|
|
|
|
it("should reset the root to an empty condition when its last group empties", function () {
|
|
// A search whose only content is a single group with a single condition
|
|
var s = new Zotero.Search();
|
|
s.libraryID = Zotero.Libraries.userLibraryID;
|
|
s.addCondition('groupStart', 'true', '');
|
|
s.addCondition('tag', 'is', 'x');
|
|
s.addCondition('groupEnd', 'true', '');
|
|
pane.search = s;
|
|
|
|
var group = conditions.querySelector('search-condition-group');
|
|
group.conditionsContainer.firstChild.onRemoveClicked();
|
|
|
|
// The emptied group is gone and the root falls back to one empty condition
|
|
assert.notOk(conditions.querySelector('search-condition-group'));
|
|
assert.lengthOf(conditions.children, 1);
|
|
assert.equal(conditions.firstChild.selectedCondition, 'title');
|
|
assert.equal(conditions.firstChild.querySelector('#valuefield').value, '');
|
|
});
|
|
|
|
it("should default a fresh search to the top-level item result level", function () {
|
|
// Opening a new (unseeded) advanced search defaults the result level to
|
|
// top-level items, so child conditions map up without grouping
|
|
pane.search = null;
|
|
assert.equal(searchBox.rootGroup.resultLevel, 'item');
|
|
});
|
|
|
|
it("should render and serialize the root result level", function () {
|
|
var s = new Zotero.Search();
|
|
s.libraryID = Zotero.Libraries.userLibraryID;
|
|
s.addCondition('resultLevel', 'annotation');
|
|
s.addCondition('annotationText', 'contains', 'foo');
|
|
pane.search = s;
|
|
|
|
assert.equal(searchBox.rootGroup.resultLevel, 'annotation');
|
|
|
|
searchBox.updateSearch();
|
|
var sequence = Object.values(searchBox.search.getConditions())
|
|
.map(c => c.condition);
|
|
assert.deepEqual(sequence, ['resultLevel', 'annotationText']);
|
|
var scopeCond = Object.values(searchBox.search.getConditions())
|
|
.find(c => c.condition == 'resultLevel');
|
|
assert.equal(scopeCond.operator, 'annotation');
|
|
});
|
|
|
|
it("should render and serialize a nested group result level", function () {
|
|
var s = new Zotero.Search();
|
|
s.libraryID = Zotero.Libraries.userLibraryID;
|
|
s.addCondition('creator', 'contains', 'Smith');
|
|
s.addCondition('groupStart', 'true', '');
|
|
s.addCondition('resultLevel', 'annotation');
|
|
s.addCondition('annotationText', 'contains', 'foo');
|
|
s.addCondition('groupEnd', 'true', '');
|
|
pane.search = s;
|
|
|
|
var group = conditions.querySelector('search-condition-group');
|
|
assert.equal(group.resultLevel, 'annotation');
|
|
|
|
searchBox.updateSearch();
|
|
var sequence = Object.values(searchBox.search.getConditions())
|
|
.map(c => c.condition);
|
|
assert.deepEqual(sequence,
|
|
['creator', 'groupStart', 'resultLevel', 'annotationText', 'groupEnd']);
|
|
});
|
|
|
|
it("should show a binding menu for a group of same-level descendant conditions", function () {
|
|
var s = new Zotero.Search();
|
|
s.libraryID = Zotero.Libraries.userLibraryID;
|
|
s.addCondition('resultLevel', 'item'); // result level
|
|
s.addCondition('groupStart', 'true', '');
|
|
s.addCondition('annotationText', 'contains', 'foo');
|
|
s.addCondition('annotationComment', 'contains', 'bar');
|
|
s.addCondition('groupEnd', 'true', '');
|
|
pane.search = s;
|
|
|
|
var group = conditions.querySelector('search-condition-group');
|
|
assert.isFalse(group.bindingMenu.hidden);
|
|
var values = [...group.bindingMenu.querySelectorAll('menuitem')].map(i => i.value);
|
|
assert.includeMembers(values, ['any', 'annotation']);
|
|
// No attachment conditions, so it isn't offered
|
|
assert.notInclude(values, 'attachment');
|
|
|
|
// Bind to the same annotation and confirm it serializes
|
|
group.resultLevel = 'annotation';
|
|
searchBox.updateSearch();
|
|
var seq = Object.values(searchBox.search.getConditions()).map(c => c.condition);
|
|
assert.deepEqual(seq,
|
|
['resultLevel', 'groupStart', 'resultLevel', 'annotationText', 'annotationComment', 'groupEnd']);
|
|
var groupScope = Object.values(searchBox.search.getConditions())
|
|
.filter(c => c.condition == 'resultLevel')[1];
|
|
assert.equal(groupScope.operator, 'annotation');
|
|
});
|
|
|
|
it("should not show a binding menu for a group of item-level conditions", function () {
|
|
var s = new Zotero.Search();
|
|
s.libraryID = Zotero.Libraries.userLibraryID;
|
|
s.addCondition('resultLevel', 'item');
|
|
s.addCondition('groupStart', 'true', '');
|
|
s.addCondition('title', 'contains', 'a');
|
|
s.addCondition('title', 'contains', 'b');
|
|
s.addCondition('groupEnd', 'true', '');
|
|
pane.search = s;
|
|
|
|
var group = conditions.querySelector('search-condition-group');
|
|
assert.isTrue(group.bindingMenu.hidden);
|
|
});
|
|
|
|
it("should render a stored group binding into the menu", function () {
|
|
var s = new Zotero.Search();
|
|
s.libraryID = Zotero.Libraries.userLibraryID;
|
|
s.addCondition('resultLevel', 'item');
|
|
s.addCondition('groupStart', 'true', '');
|
|
s.addCondition('resultLevel', 'annotation');
|
|
s.addCondition('annotationText', 'contains', 'foo');
|
|
s.addCondition('annotationComment', 'contains', 'bar');
|
|
s.addCondition('groupEnd', 'true', '');
|
|
pane.search = s;
|
|
|
|
var group = conditions.querySelector('search-condition-group');
|
|
assert.isFalse(group.bindingMenu.hidden);
|
|
assert.equal(group.resultLevel, 'annotation');
|
|
assert.equal(group.bindingMenu.value, 'annotation');
|
|
});
|
|
|
|
it("should keep showing a group binding when a condition switches level", function () {
|
|
var s = new Zotero.Search();
|
|
s.libraryID = Zotero.Libraries.userLibraryID;
|
|
s.addCondition('resultLevel', 'item');
|
|
s.addCondition('groupStart', 'true', '');
|
|
s.addCondition('resultLevel', 'annotation');
|
|
s.addCondition('annotationText', 'contains', 'foo');
|
|
s.addCondition('annotationComment', 'contains', 'bar');
|
|
s.addCondition('groupEnd', 'true', '');
|
|
pane.search = s;
|
|
|
|
var group = conditions.querySelector('search-condition-group');
|
|
assert.isFalse(group.bindingMenu.hidden);
|
|
|
|
// The binding stays visible (and clearable) rather than silently
|
|
// constraining the group from a hidden menu
|
|
group.conditionsContainer.firstChild.onConditionSelected('title');
|
|
searchBox.updateSearch();
|
|
|
|
assert.isFalse(group.bindingMenu.hidden);
|
|
assert.equal(group.bindingMenu.value, 'annotation');
|
|
var seq = Object.values(searchBox.search.getConditions()).map(c => c.condition);
|
|
assert.deepEqual(seq,
|
|
['resultLevel', 'groupStart', 'resultLevel', 'title', 'annotationComment', 'groupEnd']);
|
|
});
|
|
|
|
it("should keep showing a group binding for a lone condition at the bound level", function () {
|
|
var s = new Zotero.Search();
|
|
s.libraryID = Zotero.Libraries.userLibraryID;
|
|
s.addCondition('resultLevel', 'item');
|
|
s.addCondition('groupStart', 'true', '');
|
|
s.addCondition('resultLevel', 'annotation');
|
|
s.addCondition('annotationText', 'contains', 'foo');
|
|
s.addCondition('groupEnd', 'true', '');
|
|
pane.search = s;
|
|
|
|
var group = conditions.querySelector('search-condition-group');
|
|
assert.isFalse(group.bindingMenu.hidden);
|
|
assert.equal(group.bindingMenu.value, 'annotation');
|
|
searchBox.updateSearch();
|
|
var seq = Object.values(searchBox.search.getConditions()).map(c => c.condition);
|
|
assert.deepEqual(seq,
|
|
['resultLevel', 'groupStart', 'resultLevel', 'annotationText', 'groupEnd']);
|
|
});
|
|
|
|
it("should drop a group binding when no condition at its level remains", function () {
|
|
var s = new Zotero.Search();
|
|
s.libraryID = Zotero.Libraries.userLibraryID;
|
|
s.addCondition('resultLevel', 'item');
|
|
s.addCondition('groupStart', 'true', '');
|
|
s.addCondition('resultLevel', 'annotation');
|
|
s.addCondition('annotationText', 'contains', 'foo');
|
|
s.addCondition('annotationComment', 'contains', 'bar');
|
|
s.addCondition('groupEnd', 'true', '');
|
|
pane.search = s;
|
|
|
|
var group = conditions.querySelector('search-condition-group');
|
|
group.conditionsContainer.firstChild.onConditionSelected('title');
|
|
group.conditionsContainer.lastChild.onConditionSelected('title');
|
|
searchBox.updateSearch();
|
|
|
|
assert.isTrue(group.bindingMenu.hidden);
|
|
var seq = Object.values(searchBox.search.getConditions()).map(c => c.condition);
|
|
assert.deepEqual(seq,
|
|
['resultLevel', 'groupStart', 'title', 'title', 'groupEnd']);
|
|
});
|
|
|
|
it("should offer a binding hint for ungrouped sibling descendant conditions", function () {
|
|
var s = new Zotero.Search();
|
|
s.libraryID = Zotero.Libraries.userLibraryID;
|
|
s.addCondition('resultLevel', 'item');
|
|
s.addCondition('annotationText', 'contains', 'foo');
|
|
s.addCondition('annotationComment', 'contains', 'bar');
|
|
pane.search = s;
|
|
|
|
var hint = searchBox.querySelector('#search-binding-hint');
|
|
assert.isFalse(hint.hidden);
|
|
// One bindable level (annotation), so one suggestion line with one button
|
|
assert.lengthOf([...hint.querySelectorAll('button')], 1);
|
|
});
|
|
|
|
it("should not offer a binding hint for item-level sibling conditions", function () {
|
|
var s = new Zotero.Search();
|
|
s.libraryID = Zotero.Libraries.userLibraryID;
|
|
s.addCondition('resultLevel', 'item');
|
|
s.addCondition('title', 'contains', 'a');
|
|
s.addCondition('title', 'contains', 'b');
|
|
pane.search = s;
|
|
|
|
assert.isTrue(searchBox.querySelector('#search-binding-hint').hidden);
|
|
});
|
|
|
|
it("should offer a binding hint for descendant conditions with no values", function () {
|
|
// Two descendant conditions suggest binding as soon as they're selected, with no
|
|
// values needed
|
|
var s = new Zotero.Search();
|
|
s.libraryID = Zotero.Libraries.userLibraryID;
|
|
s.addCondition('resultLevel', 'item');
|
|
s.addCondition('annotationText', 'contains', '');
|
|
s.addCondition('annotationComment', 'contains', '');
|
|
pane.search = s;
|
|
|
|
assert.isFalse(searchBox.querySelector('#search-binding-hint').hidden);
|
|
});
|
|
|
|
it("should not offer a binding hint for a freshly added condition until it's changed", function () {
|
|
var s = new Zotero.Search();
|
|
s.libraryID = Zotero.Libraries.userLibraryID;
|
|
s.addCondition('resultLevel', 'item');
|
|
s.addCondition('annotationText', 'contains', 'foo');
|
|
pane.search = s;
|
|
|
|
// "+" seeds a second annotation condition, but it shouldn't suggest binding until
|
|
// the user engages with it
|
|
conditions.firstChild.onAddClicked({ preventDefault() {}, detail: 1 });
|
|
assert.isTrue(searchBox.querySelector('#search-binding-hint').hidden);
|
|
|
|
// Engaging with the new row (here, typing in it) lets it count
|
|
conditions.lastChild.querySelector('#search-textbox')
|
|
.dispatchEvent(new win.Event('input', { bubbles: true }));
|
|
assert.isFalse(searchBox.querySelector('#search-binding-hint').hidden);
|
|
});
|
|
|
|
it("should wrap conditions into a bound group when the hint is taken", function () {
|
|
var s = new Zotero.Search();
|
|
s.libraryID = Zotero.Libraries.userLibraryID;
|
|
s.addCondition('resultLevel', 'item');
|
|
s.addCondition('annotationText', 'contains', 'foo');
|
|
s.addCondition('annotationComment', 'contains', 'bar');
|
|
pane.search = s;
|
|
|
|
searchBox.rootGroup.bindSameEntity('annotation');
|
|
|
|
// The two conditions are now one group bound to annotation
|
|
var group = conditions.querySelector('search-condition-group');
|
|
assert.ok(group);
|
|
assert.equal(group.resultLevel, 'annotation');
|
|
assert.lengthOf(
|
|
[...group.conditionsContainer.children].filter(c => c.localName == 'zoterosearchcondition'),
|
|
2);
|
|
|
|
searchBox.updateSearch();
|
|
var seq = Object.values(searchBox.search.getConditions()).map(c => c.condition);
|
|
assert.deepEqual(seq,
|
|
['resultLevel', 'groupStart', 'resultLevel', 'annotationText', 'annotationComment', 'groupEnd']);
|
|
|
|
// Conditions are no longer ungrouped siblings, so the hint is gone
|
|
assert.isTrue(searchBox.querySelector('#search-binding-hint').hidden);
|
|
});
|
|
|
|
it("should fold a legacy noChildren into the result level", function () {
|
|
var s = new Zotero.Search();
|
|
s.libraryID = Zotero.Libraries.userLibraryID;
|
|
s.addCondition('noChildren', 'true');
|
|
s.addCondition('title', 'contains', 'foo');
|
|
pane.search = s;
|
|
|
|
// Shown as result level = top-level items, no separate checkbox
|
|
assert.equal(searchBox.rootGroup.resultLevel, 'item');
|
|
|
|
searchBox.updateSearch();
|
|
var seq = Object.values(searchBox.search.getConditions()).map(c => c.condition);
|
|
assert.notInclude(seq, 'noChildren');
|
|
assert.deepEqual(seq, ['resultLevel', 'title']);
|
|
});
|
|
|
|
|
|
it("should warn when ALL conditions can't match the same item at a mixed result level", function () {
|
|
// No result type (mixed), so an item-level and an annotation-level condition
|
|
// can't both be true of one row
|
|
var s = new Zotero.Search();
|
|
s.libraryID = Zotero.Libraries.userLibraryID;
|
|
s.addCondition('title', 'contains', 'a');
|
|
s.addCondition('annotationText', 'contains', 'b');
|
|
pane.search = s;
|
|
|
|
assert.isFalse(searchBox.querySelector('.level-warning').hidden);
|
|
});
|
|
|
|
it("should not warn when a result type lets the conditions combine", function () {
|
|
// Result type item: the annotation condition maps up, so it's satisfiable
|
|
var s = new Zotero.Search();
|
|
s.libraryID = Zotero.Libraries.userLibraryID;
|
|
s.addCondition('resultLevel', 'item');
|
|
s.addCondition('title', 'contains', 'a');
|
|
s.addCondition('annotationText', 'contains', 'b');
|
|
pane.search = s;
|
|
|
|
assert.isTrue(searchBox.querySelector('.level-warning').hidden);
|
|
});
|
|
|
|
it("should warn when a condition can't reach the result type", function () {
|
|
// A note can never be (or be under) an attachment
|
|
var s = new Zotero.Search();
|
|
s.libraryID = Zotero.Libraries.userLibraryID;
|
|
s.addCondition('resultLevel', 'attachment');
|
|
s.addCondition('note', 'contains', 'a');
|
|
pane.search = s;
|
|
|
|
assert.isFalse(searchBox.querySelector('.level-warning').hidden);
|
|
});
|
|
|
|
it("should warn on the group when a condition can't reach its binding", function () {
|
|
// A group bound to "same annotation" with a note condition: the conflict is the
|
|
// group's binding, so the warning belongs on the group, not at the root.
|
|
var s = new Zotero.Search();
|
|
s.libraryID = Zotero.Libraries.userLibraryID;
|
|
s.addCondition('resultLevel', 'item');
|
|
s.addCondition('groupStart', 'true', '');
|
|
s.addCondition('resultLevel', 'annotation');
|
|
s.addCondition('annotationComment', 'contains', 'a');
|
|
s.addCondition('note', 'contains', 'b');
|
|
s.addCondition('groupEnd', 'true', '');
|
|
pane.search = s;
|
|
|
|
var group = conditions.querySelector('search-condition-group');
|
|
assert.isFalse(group.levelWarning.hidden);
|
|
assert.isTrue(searchBox.rootGroup.levelWarning.hidden);
|
|
});
|
|
|
|
it("should not warn for a \"separately\" group whose conditions roll up", function () {
|
|
// "Separately" inherits the result level (item), so annotations and a note all
|
|
// roll up independently -- satisfiable, no warning (so "match separately" really
|
|
// does clear the bound-group warning)
|
|
var s = new Zotero.Search();
|
|
s.libraryID = Zotero.Libraries.userLibraryID;
|
|
s.addCondition('resultLevel', 'item');
|
|
s.addCondition('groupStart', 'true', '');
|
|
s.addCondition('annotationComment', 'contains', 'a');
|
|
s.addCondition('annotationComment', 'contains', 'b');
|
|
s.addCondition('note', 'contains', 'c');
|
|
s.addCondition('groupEnd', 'true', '');
|
|
pane.search = s;
|
|
|
|
assert.isTrue(conditions.querySelector('search-condition-group').levelWarning.hidden);
|
|
});
|
|
|
|
it("should not warn for an ordinary search", function () {
|
|
pane.search = null;
|
|
assert.isTrue(searchBox.querySelector('.level-warning').hidden);
|
|
});
|
|
|
|
it("should keep a legacy includeParentsAndChildren editable and round-tripping", function () {
|
|
var s = new Zotero.Search();
|
|
s.libraryID = Zotero.Libraries.userLibraryID;
|
|
s.addCondition('title', 'contains', 'foo');
|
|
s.addCondition('includeParentsAndChildren', 'true');
|
|
pane.search = s;
|
|
|
|
assert.isFalse(searchBox.querySelector('#search-legacy-options').hidden);
|
|
assert.isTrue(searchBox.querySelector('#includeParentsAndChildrenCheckbox').checked);
|
|
|
|
searchBox.updateSearch();
|
|
var seq = Object.values(searchBox.search.getConditions()).map(c => c.condition);
|
|
assert.include(seq, 'includeParentsAndChildren');
|
|
});
|
|
|
|
it("should drop includeParentsAndChildren when its legacy checkbox is unchecked", function () {
|
|
var s = new Zotero.Search();
|
|
s.libraryID = Zotero.Libraries.userLibraryID;
|
|
s.addCondition('title', 'contains', 'foo');
|
|
s.addCondition('includeParentsAndChildren', 'true');
|
|
pane.search = s;
|
|
|
|
searchBox.querySelector('#includeParentsAndChildrenCheckbox').checked = false;
|
|
searchBox.updateSearch();
|
|
var seq = Object.values(searchBox.search.getConditions()).map(c => c.condition);
|
|
assert.notInclude(seq, 'includeParentsAndChildren');
|
|
});
|
|
|
|
it("should add a sibling condition outside the group from the group's +", function () {
|
|
var s = new Zotero.Search();
|
|
s.libraryID = Zotero.Libraries.userLibraryID;
|
|
s.addCondition('title', 'contains', 'foo');
|
|
pane.search = s;
|
|
|
|
// The root has no parent, so its caption "+" stays hidden
|
|
assert.isTrue(searchBox.rootGroup.addConditionButton.hidden);
|
|
|
|
// Wrap the only root condition, so the root now holds just the group
|
|
conditions.firstChild.onGroupClicked({ preventDefault() {} });
|
|
assert.lengthOf(conditions.children, 1);
|
|
var group = conditions.querySelector('search-condition-group');
|
|
|
|
// The group's "+" adds a sibling in the parent (root), not inside the group
|
|
group.onAddSiblingClicked();
|
|
|
|
assert.lengthOf(conditions.children, 2);
|
|
var rows = [...conditions.children]
|
|
.filter(c => c.localName === 'zoterosearchcondition');
|
|
assert.lengthOf(rows, 1);
|
|
assert.equal(rows[0].selectedCondition, 'title');
|
|
// Nothing was added inside the group
|
|
assert.lengthOf(group.conditionsContainer.children, 1);
|
|
});
|
|
});
|
|
});
|
|
});
|