mirror of
https://github.com/zotero/zotero.git
synced 2026-10-01 02:01:24 +00:00
The conditions menu had its own find-as-you-type, which Utilities.Internal.addMenuFindAsYouType() now provides for any menu. Matching walks the menu rather than a cached list of every condition, so typing can no longer select a condition the menu doesn't offer -- Collection and Saved Search are removed when the search spans multiple libraries. An attachment or annotation condition is shown with a short label inside its submenu, so pass the full name to match on, which is what the menulist shows once the condition is selected.
2240 lines
82 KiB
JavaScript
2240 lines
82 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 select an attachment condition by its full 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');
|
|
|
|
// The Attachment submenu shows a short label, but the menulist shows the
|
|
// full name, which is what gets typed
|
|
let menuitem = conditionsMenu.querySelector('menuitem[value="fileTypeID"]');
|
|
assert.notEqual(
|
|
menuitem.label,
|
|
Zotero.SearchConditions.getLocalizedName('fileTypeID')
|
|
);
|
|
// The spaces in the name would otherwise open the menu (see customElements.js)
|
|
typeInMenu(conditionsMenu, 'attachment file t');
|
|
assert.equal(searchCondition.selectedCondition, 'fileTypeID');
|
|
});
|
|
|
|
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, with subcollections in submenus", 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 two top-level collections
|
|
assert.equal(valueMenu.itemCount, 2);
|
|
var col1Menu = valueMenu.getItemAtIndex(0);
|
|
assert.equal(col1Menu.getAttribute('label'), col1.name);
|
|
assert.equal(valueMenu.getItemAtIndex(1).getAttribute('label'), col4.name);
|
|
|
|
// Subcollections are nested below their parents
|
|
var col2Menu = col1Menu.menupopup.querySelector(`menu[value="${col2.treeViewID}"]`);
|
|
assert.equal(col2Menu.getAttribute('label'), col2.name);
|
|
var col3Item = col2Menu.menupopup
|
|
.querySelector(`menuitem[value="${col3.treeViewID}"]`);
|
|
assert.equal(col3Item.getAttribute('label'), col3.name);
|
|
|
|
var values = [...valueMenu.querySelectorAll('menu, menuitem')]
|
|
.map(node => node.getAttribute('value'));
|
|
assert.notInclude(values, "S" + search1.key);
|
|
assert.notInclude(values, "S" + search2.key);
|
|
|
|
// Selecting a subcollection shows it on the menulist and stores its key
|
|
col3Item.doCommand();
|
|
assert.equal(valueMenu.label, col3.name);
|
|
// The path is out of the way in a tooltip, since the menulist rarely has room
|
|
assert.equal(
|
|
valueMenu.getAttribute('tooltiptext'),
|
|
`${col1.name} \u203A ${col2.name} \u203A ${col3.name}`
|
|
);
|
|
assert.isTrue(col3Item.hasAttribute('checked'));
|
|
assert.equal(searchCondition.getConditionData().value, col3.key);
|
|
|
|
await Zotero.Collections.erase([col1.id, col2.id, col3.id, col4.id]);
|
|
await Zotero.Searches.erase([search1.id, search2.id]);
|
|
});
|
|
|
|
it("should select a subcollection in a submenu by typing its name", async function () {
|
|
var col1 = await createDataObject('collection', { name: "A" });
|
|
var col2 = await createDataObject('collection', { name: "Deep", parentID: col1.id });
|
|
|
|
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;
|
|
}
|
|
}
|
|
assert.equal(valueMenu.label, col1.name);
|
|
|
|
// "Deep" is in a submenu, so the menulist's own find-as-you-type can't reach it
|
|
valueMenu.dispatchEvent(new win.KeyboardEvent('keydown', {
|
|
key: 'd',
|
|
bubbles: true,
|
|
cancelable: true
|
|
}));
|
|
assert.equal(valueMenu.label, col2.name);
|
|
assert.equal(searchCondition.getConditionData().value, col2.key);
|
|
|
|
await Zotero.Collections.erase([col1.id, col2.id]);
|
|
});
|
|
|
|
it("should keep matching by name after the menu is rebuilt", async function () {
|
|
var col1 = await createDataObject('collection', { name: "Apple" });
|
|
var col2 = await createDataObject('collection', { name: "Banana" });
|
|
|
|
var s = new Zotero.Search();
|
|
s.libraryID = Zotero.Libraries.userLibraryID;
|
|
s.addCondition('collection', 'is', col1.key);
|
|
pane.search = s;
|
|
|
|
var searchCondition = conditions.firstChild;
|
|
var conditionsMenu = searchCondition.querySelector('#conditionsmenu');
|
|
var valueMenu = searchCondition.querySelector('#valuemenu');
|
|
|
|
// Switching to another condition and back replaces the menu
|
|
conditionsMenu.querySelector('menuitem[value="title"]').doCommand();
|
|
conditionsMenu.querySelector('menuitem[value="collection"]').doCommand();
|
|
|
|
for (let ch of 'banana') {
|
|
valueMenu.dispatchEvent(new win.KeyboardEvent('keydown', {
|
|
key: ch,
|
|
bubbles: true,
|
|
cancelable: true
|
|
}));
|
|
}
|
|
assert.equal(valueMenu.label, col2.name);
|
|
assert.equal(searchCondition.getConditionData().value, col2.key);
|
|
|
|
await Zotero.Collections.erase([col1.id, col2.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;
|
|
}
|
|
}
|
|
valueMenu.querySelector(`menuitem[value="${collection1.treeViewID}"]`).doCommand();
|
|
assert.equal(valueMenu.label, collection1.name);
|
|
assert.equal(searchCondition.getConditionData().value, 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.label, collection2.name);
|
|
assert.equal(searchCondition.getConditionData().value, collection2.key);
|
|
for (let i = 0; i < valueMenu.itemCount; i++) {
|
|
let menuitem = valueMenu.getItemAtIndex(i);
|
|
values.push(menuitem.getAttribute('value'));
|
|
}
|
|
assert.notInclude(values, collection1.treeViewID);
|
|
assert.include(values, collection2.treeViewID);
|
|
|
|
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);
|
|
});
|
|
});
|
|
});
|
|
});
|