"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); }); }); }); });