mirror of
https://github.com/zotero/zotero.git
synced 2026-10-09 03:18:01 +00:00
Collections are now tracked as they're selected, added to, or dropped on, and the five most recent usable targets are listed above the full collection hierarchy by full path.
2689 lines
104 KiB
JavaScript
2689 lines
104 KiB
JavaScript
"use strict";
|
|
|
|
describe("ZoteroPane", function () {
|
|
var win, doc, zp, userLibraryID;
|
|
|
|
// Load Zotero pane and select library
|
|
before(function* () {
|
|
win = yield loadZoteroPane();
|
|
doc = win.document;
|
|
zp = win.ZoteroPane;
|
|
userLibraryID = Zotero.Libraries.userLibraryID;
|
|
});
|
|
|
|
after(function () {
|
|
win.close();
|
|
});
|
|
|
|
describe("#_setHighlightedRowsCallback()", function () {
|
|
it("should highlight containing collection of selected item on Ctrl/Option", async function () {
|
|
var collection = await createDataObject('collection');
|
|
var item = await createDataObject('item', { collections: [collection.id] });
|
|
|
|
await selectLibrary(win);
|
|
await zp.itemsView.selectItem(item.id);
|
|
|
|
var itemTree = doc.getElementById("item-tree-main-default");
|
|
itemTree.focus();
|
|
assert.equal(doc.activeElement.id, "item-tree-main-default");
|
|
|
|
var key = Zotero.isMac ? "Alt" : "Control";
|
|
itemTree.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
|
|
|
|
// The handler sets a 225ms timer before applying highlights
|
|
await waitForCallback(() => doc.querySelectorAll('.highlighted').length > 0, 50, 5);
|
|
|
|
var rows = doc.querySelectorAll('.highlighted');
|
|
assert.lengthOf(rows, 1);
|
|
|
|
await zp.collectionsView.setHighlightedRows();
|
|
});
|
|
|
|
it("should highlight parent collection of collection in trash", async function () {
|
|
var collection1 = await createDataObject('collection');
|
|
var collection2 = await createDataObject('collection', { parentID: collection1.id, deleted: true });
|
|
|
|
await selectTrash(win);
|
|
|
|
var row = zp.itemsView.getRowIndexByID(collection2.treeViewID);
|
|
zp.itemsView.selection.select(row);
|
|
|
|
var spy = sinon.spy(zp.collectionsView, 'setHighlightedRows');
|
|
await zp._setHighlightedRowsCallback();
|
|
|
|
assert.sameMembers(spy.getCall(0).args[0], [collection1.treeViewID]);
|
|
var rows = win.document.querySelectorAll('.highlighted');
|
|
assert.lengthOf(rows, 1);
|
|
|
|
await zp.collectionsView.setHighlightedRows();
|
|
|
|
spy.restore();
|
|
// Switch back to library to avoid breaking other tests
|
|
await selectLibrary(win);
|
|
});
|
|
});
|
|
|
|
describe("#newItem", function () {
|
|
it("should create an item and focus the title field", async function () {
|
|
await zp.newItem(Zotero.ItemTypes.getID('book'), {}, null, true);
|
|
assert.equal(doc.activeElement.closest("editable-text").id, "itembox-field-value-title");
|
|
doc.activeElement.blur();
|
|
await Zotero.Promise.delay(1);
|
|
})
|
|
|
|
it("should save an entered value when New Item is used", async function () {
|
|
var value = "Test";
|
|
var item = await zp.newItem(Zotero.ItemTypes.getID('book'), {}, null, true);
|
|
let header = doc.getElementById('zotero-item-pane-header');
|
|
let title = header.querySelector("editable-text");
|
|
title.value = value;
|
|
await header.save();
|
|
item = await Zotero.Items.getAsync(item.id);
|
|
assert.equal(item.getField('title'), value);
|
|
})
|
|
});
|
|
|
|
describe("#newNote()", function () {
|
|
it("should create a child note and select it", async function () {
|
|
var item = await createDataObject('item');
|
|
var noteID = await zp.newNote(false, item.key, "Test");
|
|
var selected = zp.itemsView.getSelectedItems(true);
|
|
assert.lengthOf(selected, 1);
|
|
assert.equal(selected, noteID);
|
|
})
|
|
|
|
it("should create a standalone note within a collection and select it", async function () {
|
|
var collection = await createDataObject('collection');
|
|
await select(win, collection);
|
|
var noteID = await zp.newNote(false, false, "Test");
|
|
assert.equal(zp.collectionsView.getSelectedCollections()[0], collection);
|
|
var selected = zp.itemsView.getSelectedItems(true);
|
|
assert.lengthOf(selected, 1);
|
|
assert.equal(selected, noteID);
|
|
})
|
|
})
|
|
|
|
describe("#newCollection()", function () {
|
|
it("should create a collection", async function () {
|
|
var promise = waitForDialog(
|
|
null,
|
|
'accept',
|
|
'chrome://zotero/content/newCollectionDialog.xhtml'
|
|
);
|
|
var id = await zp.newCollection();
|
|
await promise;
|
|
var collection = Zotero.Collections.get(id);
|
|
assert.isTrue(collection.name.startsWith(Zotero.getString('pane.collections.untitled')));
|
|
});
|
|
});
|
|
|
|
describe("Advanced Search", function () {
|
|
it("should create a saved search", async function () {
|
|
await selectLibrary(win);
|
|
await zp.toggleAdvancedSearchState('open');
|
|
var deck = doc.getElementById('zotero-advanced-search-pane-deck');
|
|
|
|
var searchIDs = (await Zotero.Searches.getAll(userLibraryID)).map(s => s.id);
|
|
// Saving prompts for a name; accept the default
|
|
var promptService = Services.prompt;
|
|
Services.prompt = { prompt: () => true };
|
|
try {
|
|
await deck.pane.save();
|
|
}
|
|
finally {
|
|
Services.prompt = promptService;
|
|
}
|
|
|
|
var newSearches = (await Zotero.Searches.getAll(userLibraryID))
|
|
.filter(s => !searchIDs.includes(s.id));
|
|
assert.lengthOf(newSearches, 1);
|
|
assert.isTrue(newSearches[0].name.startsWith(Zotero.getString('pane.collections.untitled')));
|
|
assert.equal(deck.state, 'closed');
|
|
|
|
await newSearches[0].eraseTx();
|
|
});
|
|
});
|
|
|
|
describe("#itemSelected()", function () {
|
|
it.skip("should update the item count", function* () {
|
|
var collection = new Zotero.Collection;
|
|
collection.name = "Count Test";
|
|
var id = yield collection.saveTx();
|
|
yield waitForItemsLoad(win);
|
|
|
|
// Unselected, with no items in view
|
|
assert.equal(
|
|
doc.getElementById('zotero-item-pane-message-box').textContent,
|
|
yield doc.l10n.formatValue('item-pane-message-unselected', { count: 0 })
|
|
);
|
|
|
|
// Unselected, with one item in view
|
|
var item = new Zotero.Item('newspaperArticle');
|
|
item.setCollections([id]);
|
|
var itemID1 = yield item.saveTx({
|
|
skipSelect: true
|
|
});
|
|
assert.equal(
|
|
doc.getElementById('zotero-item-pane-message-box').textContent,
|
|
yield doc.l10n.formatValue('item-pane-message-unselected', { count: 1 })
|
|
);
|
|
|
|
// Unselected, with multiple items in view
|
|
var item = new Zotero.Item('audioRecording');
|
|
item.setCollections([id]);
|
|
var itemID2 = yield item.saveTx({
|
|
skipSelect: true
|
|
});
|
|
assert.equal(
|
|
doc.getElementById('zotero-item-pane-message-box').textContent,
|
|
yield doc.l10n.formatValue('item-pane-message-unselected', { count: 2 })
|
|
);
|
|
|
|
// Multiple items selected
|
|
var promise = zp.itemsView._getItemSelectedPromise();
|
|
zp.itemsView.rememberSelection([itemID1, itemID2]);
|
|
yield promise;
|
|
assert.equal(
|
|
doc.getElementById('zotero-item-pane-message-box').textContent,
|
|
Zotero.getString('pane.item.selected.multiple', 2)
|
|
);
|
|
})
|
|
|
|
it("should update the item count when filtering with nothing selected", async function () {
|
|
var collection = await createDataObject('collection');
|
|
await createDataObject('item', { collections: [collection.id], title: 'Perez study' });
|
|
await createDataObject('item', { collections: [collection.id], title: 'Unrelated paper' });
|
|
|
|
await zp.collectionsView.selectByID("C" + collection.id);
|
|
await waitForItemsLoad(win);
|
|
|
|
var messageBox = doc.getElementById('zotero-item-pane-message-box');
|
|
var twoInView = await doc.l10n.formatValue('item-pane-message-unselected', { count: 2 });
|
|
var oneInView = await doc.l10n.formatValue('item-pane-message-unselected', { count: 1 });
|
|
|
|
// Nothing selected, both items shown
|
|
await waitForCallback(() => messageBox.textContent == twoInView, 100, 5);
|
|
|
|
// Quick search filters to one item; the count should update even though the
|
|
// selection (nothing) hasn't changed
|
|
await zp.itemsView.setFilter('search', 'perez');
|
|
await waitForCallback(() => messageBox.textContent == oneInView, 100, 5);
|
|
|
|
await zp.itemsView.setFilter('search', '');
|
|
await collection.eraseTx();
|
|
})
|
|
})
|
|
|
|
describe("#viewAttachment", function () {
|
|
var apiKey = Zotero.Utilities.randomString(24);
|
|
var baseURL;
|
|
var httpd;
|
|
var server;
|
|
|
|
async function downloadOnDemand() {
|
|
var item = new Zotero.Item("attachment");
|
|
item.attachmentLinkMode = 'imported_file';
|
|
item.attachmentPath = 'storage:test.txt';
|
|
// TODO: Test binary data
|
|
var text = Zotero.Utilities.randomString();
|
|
item.attachmentSyncState = "to_download";
|
|
await item.saveTx();
|
|
|
|
var mtime = "1441252524000";
|
|
var md5 = Zotero.Utilities.Internal.md5(text)
|
|
|
|
var s3Path = `pretend-s3/${item.key}`;
|
|
server.respondWith(function (req) {
|
|
if (req.method == "GET"
|
|
&& req.url == baseURL + `users/1/items/${item.key}/file`) {
|
|
req.respond(302, {
|
|
"Zotero-File-Modification-Time": mtime,
|
|
"Zotero-File-MD5": md5,
|
|
"Zotero-File-Compressed": "No",
|
|
"Location": baseURL + s3Path,
|
|
}, "");
|
|
}
|
|
});
|
|
httpd.registerPathHandler(
|
|
"/" + s3Path,
|
|
{
|
|
handle: function (request, response) {
|
|
response.setStatusLine(null, 200, "OK");
|
|
response.write(text);
|
|
}
|
|
}
|
|
);
|
|
|
|
// Disable loadURI() so viewAttachment() doesn't trigger translator loading
|
|
var stub = sinon.stub(Zotero, "launchFile");
|
|
|
|
await zp.viewAttachment(item.id);
|
|
|
|
assert.ok(stub.calledOnce);
|
|
assert.ok(stub.calledWith(item.getFilePath()));
|
|
stub.restore();
|
|
|
|
assert.equal(await item.attachmentHash, md5);
|
|
assert.equal(await item.attachmentModificationTime, mtime);
|
|
var path = await item.getFilePathAsync();
|
|
assert.equal(await Zotero.File.getContentsAsync(path), text);
|
|
};
|
|
|
|
before(function () {
|
|
Zotero.HTTP.mock = sinon.FakeXMLHttpRequest;
|
|
})
|
|
beforeEach(async function () {
|
|
var port;
|
|
({ httpd, port } = await startHTTPServer());
|
|
baseURL = `http://localhost:${port}/`;
|
|
Zotero.Prefs.set("api.url", baseURL);
|
|
|
|
server = sinon.fakeServer.create();
|
|
server.autoRespond = true;
|
|
|
|
Zotero.Sync.Runner.apiKey = apiKey;
|
|
await Zotero.Users.setCurrentUserID(1);
|
|
await Zotero.Users.setCurrentUsername("testuser");
|
|
})
|
|
afterEach(function* () {
|
|
server.restore();
|
|
var defer = Zotero.Promise.defer();
|
|
httpd.stop(() => defer.resolve());
|
|
yield defer.promise;
|
|
})
|
|
after(function () {
|
|
Zotero.HTTP.mock = null;
|
|
});
|
|
|
|
it("should download an attachment on-demand in as-needed mode", async function () {
|
|
Zotero.Sync.Storage.Local.downloadAsNeeded(Zotero.Libraries.userLibraryID, true);
|
|
await downloadOnDemand();
|
|
});
|
|
|
|
// As noted in viewAttachment(), this is only necessary for files modified before 5.0.85
|
|
it("should re-download a remotely modified attachment in as-needed mode", async function () {
|
|
Zotero.Sync.Storage.Local.downloadAsNeeded(Zotero.Libraries.userLibraryID, true);
|
|
|
|
var item = await importFileAttachment('test.txt');
|
|
item.attachmentSyncState = "to_download";
|
|
await item.saveTx();
|
|
|
|
var text = Zotero.Utilities.randomString();
|
|
var mtime = "1441252524000";
|
|
var md5 = Zotero.Utilities.Internal.md5(text)
|
|
|
|
var s3Path = `pretend-s3/${item.key}`;
|
|
server.respondWith(function (req) {
|
|
if (req.method == "GET"
|
|
&& req.url == baseURL + `users/1/items/${item.key}/file`) {
|
|
req.respond(302, {
|
|
"Zotero-File-Modification-Time": mtime,
|
|
"Zotero-File-MD5": md5,
|
|
"Zotero-File-Compressed": "No",
|
|
"Location": baseURL + s3Path,
|
|
}, "");
|
|
}
|
|
});
|
|
httpd.registerPathHandler(
|
|
"/" + s3Path,
|
|
{
|
|
handle: function (request, response) {
|
|
response.setStatusLine(null, 200, "OK");
|
|
response.write(text);
|
|
}
|
|
}
|
|
);
|
|
|
|
// Disable loadURI() so viewAttachment() doesn't trigger translator loading
|
|
var downloadSpy = sinon.spy(Zotero.Sync.Runner, "downloadFile");
|
|
var launchFileStub = sinon.stub(Zotero, "launchFile");
|
|
|
|
await zp.viewAttachment(item.id);
|
|
|
|
assert.ok(downloadSpy.calledOnce);
|
|
assert.ok(launchFileStub.calledOnce);
|
|
assert.ok(launchFileStub.calledWith(item.getFilePath()));
|
|
downloadSpy.restore();
|
|
launchFileStub.restore();
|
|
|
|
assert.equal(await item.attachmentHash, md5);
|
|
assert.equal(await item.attachmentModificationTime, mtime);
|
|
var path = await item.getFilePathAsync();
|
|
assert.equal(await Zotero.File.getContentsAsync(path), text);
|
|
});
|
|
|
|
it("should handle a 404 when re-downloading a remotely modified attachment in as-needed mode", async function () {
|
|
Zotero.Sync.Storage.Local.downloadAsNeeded(Zotero.Libraries.userLibraryID, true);
|
|
|
|
var item = await importFileAttachment('test.txt');
|
|
item.attachmentSyncState = "to_download";
|
|
await item.saveTx();
|
|
|
|
var mtime = await item.attachmentModificationTime;
|
|
var md5 = await item.attachmentHash;
|
|
var text = await Zotero.File.getContentsAsync(item.getFilePath());
|
|
|
|
httpd.registerPathHandler(
|
|
`/users/1/items/${item.key}/file`,
|
|
{
|
|
handle: function (request, response) {
|
|
response.setStatusLine(null, 404, "Not Found");
|
|
}
|
|
}
|
|
);
|
|
|
|
// Disable loadURI() so viewAttachment() doesn't trigger translator loading
|
|
var downloadSpy = sinon.spy(Zotero.Sync.Runner, "downloadFile");
|
|
var launchFileStub = sinon.stub(Zotero, "launchFile");
|
|
|
|
await zp.viewAttachment(item.id);
|
|
|
|
assert.ok(downloadSpy.calledOnce);
|
|
assert.ok(launchFileStub.calledOnce);
|
|
assert.ok(launchFileStub.calledWith(item.getFilePath()));
|
|
downloadSpy.restore();
|
|
launchFileStub.restore();
|
|
|
|
// File shouldn't have changed
|
|
assert.equal(await item.attachmentModificationTime, mtime);
|
|
assert.equal(await item.attachmentHash, md5);
|
|
var path = await item.getFilePathAsync();
|
|
assert.equal(await Zotero.File.getContentsAsync(path), text);
|
|
});
|
|
|
|
it("should download an attachment on-demand in at-sync-time mode", async function () {
|
|
Zotero.Sync.Storage.Local.downloadOnSync(Zotero.Libraries.userLibraryID, true);
|
|
await downloadOnDemand();
|
|
});
|
|
|
|
it("should update a PDF with a blank MIME type", async function () {
|
|
let attachment = await importFileAttachment('test.pdf');
|
|
// Can't use contentType argument to importFileAttachment() because blank string is ignored
|
|
attachment.attachmentContentType = '';
|
|
await attachment.saveTx();
|
|
await zp.viewAttachment(attachment.id);
|
|
assert.equal(attachment.attachmentContentType, 'application/pdf');
|
|
});
|
|
|
|
it("should update an EPUB with an 'application/epub' MIME type", async function () {
|
|
let attachment = await importFileAttachment('stub.epub', { contentType: 'application/epub' });
|
|
assert.equal(attachment.attachmentContentType, 'application/epub');
|
|
await zp.viewAttachment(attachment.id);
|
|
assert.equal(attachment.attachmentContentType, 'application/epub+zip');
|
|
});
|
|
|
|
it("should update an EPUB with an 'application/octet-stream' MIME type", async function () {
|
|
let attachment = await importFileAttachment('stub.epub', { contentType: 'application/octet-stream' });
|
|
assert.equal(attachment.attachmentContentType, 'application/octet-stream');
|
|
await zp.viewAttachment(attachment.id);
|
|
assert.equal(attachment.attachmentContentType, 'application/epub+zip');
|
|
});
|
|
|
|
it("should handle Windows paths on macOS/Linux", async function () {
|
|
if (!Zotero.isMac && !Zotero.isLinux) {
|
|
this.skip();
|
|
return;
|
|
}
|
|
|
|
let file = getTestDataDirectory();
|
|
file.append('test.pdf');
|
|
let attachment = await Zotero.Attachments.linkFromFile({ file });
|
|
attachment.attachmentPath = 'C:\\some\\windows\\path';
|
|
await attachment.saveTx();
|
|
|
|
let stub = sinon.stub(zp, 'showAttachmentNotFoundDialog');
|
|
await zp.viewAttachment(attachment.id);
|
|
assert.ok(stub.calledOnce);
|
|
assert.ok(stub.calledWith(attachment));
|
|
stub.restore();
|
|
});
|
|
})
|
|
|
|
|
|
describe("#addNoteFromAnnotationsFromSelected()", function () {
|
|
it("should create a single note within a selected regular item for all child attachments", async function () {
|
|
var item = await createDataObject('item');
|
|
var attachment1 = await importPDFAttachment(item);
|
|
var attachment2 = await importPDFAttachment(item);
|
|
var annotation1 = await createAnnotation('highlight', attachment1);
|
|
var annotation2 = await createAnnotation('highlight', attachment1);
|
|
var annotation3 = await createAnnotation('highlight', attachment2);
|
|
var annotation4 = await createAnnotation('highlight', attachment2);
|
|
await zp.selectItems([item.id]);
|
|
await zp.addNoteFromAnnotationsFromSelected();
|
|
var newItems = zp.getSelectedItems();
|
|
assert.lengthOf(newItems, 1);
|
|
var note = newItems[0];
|
|
assert.equal(note.itemType, 'note');
|
|
assert.equal(note.parentID, item.id);
|
|
var dp = new DOMParser();
|
|
var doc = dp.parseFromString(note.getNote(), 'text/html');
|
|
assert.sameMembers(
|
|
[...doc.querySelectorAll('h3')].map(x => x.textContent),
|
|
[attachment1.getField('title'), attachment2.getField('title')]
|
|
);
|
|
assert.lengthOf([...doc.querySelectorAll('h3 + p')], 2);
|
|
assert.lengthOf([...doc.querySelectorAll('span.highlight')], 4);
|
|
});
|
|
|
|
it("should create a single note within the parent for all selected sibling attachments", async function () {
|
|
var item = await createDataObject('item');
|
|
var attachment1 = await importPDFAttachment(item);
|
|
var attachment2 = await importPDFAttachment(item);
|
|
var annotation1 = await createAnnotation('highlight', attachment1);
|
|
var annotation2 = await createAnnotation('highlight', attachment1);
|
|
var annotation3 = await createAnnotation('highlight', attachment2);
|
|
var annotation4 = await createAnnotation('highlight', attachment2);
|
|
await zp.selectItems([attachment1.id, attachment2.id]);
|
|
await zp.addNoteFromAnnotationsFromSelected();
|
|
var newItems = zp.getSelectedItems();
|
|
assert.lengthOf(newItems, 1);
|
|
var note = newItems[0];
|
|
assert.equal(note.parentID, item.id);
|
|
var dp = new DOMParser();
|
|
var doc = dp.parseFromString(note.getNote(), 'text/html');
|
|
assert.sameMembers(
|
|
[...doc.querySelectorAll('h3')].map(x => x.textContent),
|
|
[attachment1.getField('title'), attachment2.getField('title')]
|
|
);
|
|
// No item titles
|
|
assert.lengthOf([...doc.querySelectorAll('h2 + p')], 0);
|
|
// Just attachment titles
|
|
assert.lengthOf([...doc.querySelectorAll('h3 + p')], 2);
|
|
assert.lengthOf([...doc.querySelectorAll('span.highlight')], 4);
|
|
});
|
|
|
|
it("should ignore top-level item if child attachment is also selected", async function () {
|
|
var item = await createDataObject('item');
|
|
var attachment1 = await importPDFAttachment(item);
|
|
var attachment2 = await importPDFAttachment(item);
|
|
await createAnnotation('highlight', attachment1);
|
|
await createAnnotation('highlight', attachment1);
|
|
await createAnnotation('highlight', attachment2);
|
|
await zp.selectItems([item.id, attachment1.id]);
|
|
await zp.addNoteFromAnnotationsFromSelected();
|
|
var newItems = zp.getSelectedItems();
|
|
assert.lengthOf(newItems, 1);
|
|
var note = newItems[0];
|
|
var dp = new DOMParser();
|
|
var doc = dp.parseFromString(note.getNote(), 'text/html');
|
|
// No titles
|
|
assert.lengthOf([...doc.querySelectorAll('h2 + p')], 0);
|
|
assert.lengthOf([...doc.querySelectorAll('h3 + p')], 0);
|
|
assert.lengthOf([...doc.querySelectorAll('span.highlight')], 2);
|
|
});
|
|
|
|
it("shouldn't do anything if parent item and child note is selected", async function () {
|
|
var item = await createDataObject('item');
|
|
var attachment = await importPDFAttachment(item);
|
|
var note = await createDataObject('item', { itemType: 'note', parentID: item.id });
|
|
await createAnnotation('highlight', attachment);
|
|
await zp.selectItems([item.id, note.id]);
|
|
await zp.addNoteFromAnnotationsFromSelected();
|
|
var selectedItems = zp.getSelectedItems();
|
|
assert.lengthOf(selectedItems, 2);
|
|
assert.sameMembers(selectedItems, [item, note]);
|
|
});
|
|
});
|
|
|
|
|
|
describe("#createStandaloneNoteFromAnnotationsFromSelected()", function () {
|
|
it("should create a single standalone note for all child attachments of selected regular items", async function () {
|
|
var collection = await createDataObject('collection');
|
|
await select(win, collection);
|
|
var item1 = await createDataObject('item', { setTitle: true, collections: [collection.id] });
|
|
var item2 = await createDataObject('item', { setTitle: true, collections: [collection.id] });
|
|
var attachment1 = await importPDFAttachment(item1);
|
|
var attachment2 = await importPDFAttachment(item1);
|
|
var attachment3 = await importPDFAttachment(item2);
|
|
var attachment4 = await importPDFAttachment(item2);
|
|
await createAnnotation('highlight', attachment1);
|
|
await createAnnotation('highlight', attachment1);
|
|
await createAnnotation('highlight', attachment2);
|
|
await createAnnotation('highlight', attachment2);
|
|
await createAnnotation('highlight', attachment3);
|
|
await createAnnotation('highlight', attachment3);
|
|
await createAnnotation('highlight', attachment4);
|
|
await createAnnotation('highlight', attachment4);
|
|
await zp.selectItems([item1.id, item2.id]);
|
|
await zp.createStandaloneNoteFromAnnotationsFromSelected();
|
|
var newItems = zp.getSelectedItems();
|
|
assert.lengthOf(newItems, 1);
|
|
var note = newItems[0];
|
|
assert.equal(note.itemType, 'note');
|
|
assert.isFalse(note.parentID);
|
|
assert.isTrue(collection.hasItem(note));
|
|
var dp = new DOMParser();
|
|
var doc = dp.parseFromString(note.getNote(), 'text/html');
|
|
assert.sameMembers(
|
|
[...doc.querySelectorAll('h2')].map(x => x.textContent),
|
|
[item1.getDisplayTitle(), item2.getDisplayTitle()]
|
|
);
|
|
assert.sameMembers(
|
|
[...doc.querySelectorAll('h3')].map(x => x.textContent),
|
|
[
|
|
attachment1.getField('title'),
|
|
attachment2.getField('title'),
|
|
attachment3.getField('title'),
|
|
attachment4.getField('title')
|
|
]
|
|
);
|
|
assert.lengthOf([...doc.querySelectorAll('h3 + p')], 4);
|
|
assert.lengthOf([...doc.querySelectorAll('span.highlight')], 8);
|
|
});
|
|
|
|
it("should create a single standalone note for all selected attachments", async function () {
|
|
var collection = await createDataObject('collection');
|
|
await select(win, collection);
|
|
var item1 = await createDataObject('item', { setTitle: true, collections: [collection.id] });
|
|
var item2 = await createDataObject('item', { setTitle: true, collections: [collection.id] });
|
|
var attachment1 = await importPDFAttachment(item1);
|
|
var attachment2 = await importPDFAttachment(item1);
|
|
var attachment3 = await importPDFAttachment(item2);
|
|
var attachment4 = await importPDFAttachment(item2);
|
|
await createAnnotation('highlight', attachment1);
|
|
await createAnnotation('highlight', attachment1);
|
|
await createAnnotation('highlight', attachment2);
|
|
await createAnnotation('highlight', attachment2);
|
|
await createAnnotation('highlight', attachment3);
|
|
await createAnnotation('highlight', attachment3);
|
|
await createAnnotation('highlight', attachment4);
|
|
await createAnnotation('highlight', attachment4);
|
|
await zp.selectItems([attachment1.id, attachment3.id]);
|
|
await zp.createStandaloneNoteFromAnnotationsFromSelected();
|
|
var newItems = zp.getSelectedItems();
|
|
assert.lengthOf(newItems, 1);
|
|
var note = newItems[0];
|
|
assert.isFalse(note.parentID);
|
|
assert.isTrue(collection.hasItem(note));
|
|
var dp = new DOMParser();
|
|
var doc = dp.parseFromString(note.getNote(), 'text/html');
|
|
assert.sameMembers(
|
|
[...doc.querySelectorAll('h2')].map(x => x.textContent),
|
|
[item1.getDisplayTitle(), item2.getDisplayTitle()]
|
|
);
|
|
assert.lengthOf([...doc.querySelectorAll('h2 + p')], 2);
|
|
assert.lengthOf([...doc.querySelectorAll('h3')], 0);
|
|
assert.lengthOf([...doc.querySelectorAll('span.highlight')], 4);
|
|
});
|
|
|
|
it("should ignore top-level item if child attachment is also selected", async function () {
|
|
var item1 = await createDataObject('item', { setTitle: true });
|
|
var item2 = await createDataObject('item', { setTitle: true });
|
|
var attachment1 = await importPDFAttachment(item1);
|
|
var attachment2 = await importPDFAttachment(item1);
|
|
var attachment3 = await importPDFAttachment(item2);
|
|
var attachment4 = await importPDFAttachment(item2);
|
|
await createAnnotation('highlight', attachment1);
|
|
await createAnnotation('highlight', attachment1);
|
|
await createAnnotation('highlight', attachment2);
|
|
await createAnnotation('highlight', attachment2);
|
|
await createAnnotation('highlight', attachment3);
|
|
await createAnnotation('highlight', attachment3);
|
|
await createAnnotation('highlight', attachment4);
|
|
await createAnnotation('highlight', attachment4);
|
|
await zp.selectItems([item1.id, attachment1.id, attachment3.id]);
|
|
await zp.createStandaloneNoteFromAnnotationsFromSelected();
|
|
var newItems = zp.getSelectedItems();
|
|
assert.lengthOf(newItems, 1);
|
|
var note = newItems[0];
|
|
var dp = new DOMParser();
|
|
var doc = dp.parseFromString(note.getNote(), 'text/html');
|
|
assert.sameMembers(
|
|
[...doc.querySelectorAll('h2')].map(x => x.textContent),
|
|
[item1.getDisplayTitle(), item2.getDisplayTitle()]
|
|
);
|
|
assert.lengthOf([...doc.querySelectorAll('h2 + p')], 2);
|
|
assert.lengthOf([...doc.querySelectorAll('h3')], 0);
|
|
assert.lengthOf([...doc.querySelectorAll('span.highlight')], 4);
|
|
});
|
|
});
|
|
|
|
|
|
describe("#duplicateSelectedItem()", function () {
|
|
it("should add reverse relations", async function () {
|
|
await selectLibrary(win);
|
|
var item1 = await createDataObject('item');
|
|
var item2 = await createDataObject('item');
|
|
item1.addRelatedItem(item2);
|
|
await item1.saveTx();
|
|
item2.addRelatedItem(item1);
|
|
await item2.saveTx();
|
|
var item3 = await zp.duplicateSelectedItem();
|
|
assert.sameMembers(item3.relatedItems, [item1.key]);
|
|
assert.sameMembers(item2.relatedItems, [item1.key]);
|
|
assert.sameMembers(item1.relatedItems, [item2.key, item3.key]);
|
|
});
|
|
});
|
|
|
|
|
|
describe("#duplicateAndConvertSelectedItem()", function () {
|
|
describe("book to book section", function () {
|
|
it("should not add relations to other book sections for the same book", async function () {
|
|
await selectLibrary(win);
|
|
var bookItem = await createDataObject('item', { itemType: 'book', title: "Book Title" });
|
|
|
|
// Relate book to another book section with a different title
|
|
var otherBookSection = createUnsavedDataObject('item', { itemType: 'bookSection', setTitle: true })
|
|
otherBookSection.setField('bookTitle', "Another Book Title");
|
|
await otherBookSection.saveTx();
|
|
bookItem.addRelatedItem(otherBookSection);
|
|
await bookItem.saveTx();
|
|
otherBookSection.addRelatedItem(bookItem);
|
|
await otherBookSection.saveTx();
|
|
|
|
await zp.selectItem(bookItem.id);
|
|
var bookSectionItem1 = await zp.duplicateAndConvertSelectedItem();
|
|
await zp.selectItem(bookItem.id);
|
|
var bookSectionItem2 = await zp.duplicateAndConvertSelectedItem();
|
|
|
|
// Book sections should only be related to parent
|
|
assert.sameMembers(bookSectionItem1.relatedItems, [bookItem.key, otherBookSection.key]);
|
|
assert.sameMembers(bookSectionItem2.relatedItems, [bookItem.key, otherBookSection.key]);
|
|
});
|
|
});
|
|
|
|
it("should not copy abstracts", async function () {
|
|
await selectLibrary(win);
|
|
var bookItem = await createDataObject('item', { itemType: 'book', title: "Book Title" });
|
|
bookItem.setField('abstractNote', 'An abstract');
|
|
bookItem.saveTx();
|
|
|
|
var bookSectionItem = await zp.duplicateAndConvertSelectedItem();
|
|
assert.isEmpty(bookSectionItem.getField('abstractNote'));
|
|
});
|
|
|
|
it("should not copy DOIs", async function () {
|
|
await selectLibrary(win);
|
|
var bookItem = await createDataObject('item', { itemType: 'book', title: "Book Title" });
|
|
bookItem.setField('DOI', '10.1515/9783111453750');
|
|
bookItem.saveTx();
|
|
|
|
var bookSectionItem = await zp.duplicateAndConvertSelectedItem();
|
|
assert.isEmpty(bookSectionItem.getField('DOI'));
|
|
|
|
bookSectionItem.setField('DOI', '10.1515/9783111453750-003');
|
|
await bookSectionItem.saveTx();
|
|
bookItem = await zp.duplicateAndConvertSelectedItem();
|
|
assert.isEmpty(bookItem.getField('DOI'));
|
|
});
|
|
});
|
|
|
|
|
|
describe("#deleteSelectedItems()", function () {
|
|
afterEach(async function () {
|
|
await selectLibrary(win);
|
|
});
|
|
|
|
it("should be called on Delete key", async function () {
|
|
var item = await createDataObject('item');
|
|
var iv = zp.itemsView;
|
|
iv.selectItem(item.id);
|
|
|
|
var tree = doc.getElementById(iv.id);
|
|
tree.focus();
|
|
|
|
var stub = sinon.stub(zp, 'deleteSelectedItems');
|
|
|
|
// Unmodified Delete -- force should be false
|
|
tree.dispatchEvent(new KeyboardEvent(
|
|
"keypress",
|
|
{
|
|
key: 'Delete',
|
|
code: 'Delete',
|
|
keyCode: 46,
|
|
bubbles: true,
|
|
cancelable: true,
|
|
}
|
|
));
|
|
assert.isTrue(stub.calledOnce);
|
|
assert.isFalse(stub.firstCall.args[0]);
|
|
stub.resetHistory();
|
|
|
|
// Modified Delete -- force should be true
|
|
tree.dispatchEvent(new KeyboardEvent(
|
|
"keypress",
|
|
{
|
|
key: 'Delete',
|
|
code: 'Delete',
|
|
keyCode: 46,
|
|
bubbles: true,
|
|
cancelable: true,
|
|
metaKey: Zotero.isMac,
|
|
shiftKey: !Zotero.isMac,
|
|
}
|
|
));
|
|
assert.isTrue(stub.calledOnce);
|
|
assert.isTrue(stub.firstCall.args[0]);
|
|
|
|
stub.restore();
|
|
});
|
|
|
|
it("should remove an item from My Publications", async function () {
|
|
var item = createUnsavedDataObject('item');
|
|
item.inPublications = true;
|
|
await item.saveTx();
|
|
|
|
await zp.collectionsView.selectByID("P" + userLibraryID);
|
|
await waitForItemsLoad(win);
|
|
var iv = zp.itemsView;
|
|
|
|
var selected = iv.selectItem(item.id);
|
|
assert.ok(selected);
|
|
|
|
var promise = waitForDialog();
|
|
await zp.deleteSelectedItems();
|
|
await promise;
|
|
|
|
assert.isFalse(item.inPublications);
|
|
assert.isFalse(item.deleted);
|
|
});
|
|
|
|
it("should move My Publications item to trash with prompt for modified Delete", async function () {
|
|
var item = createUnsavedDataObject('item');
|
|
item.inPublications = true;
|
|
await item.saveTx();
|
|
|
|
await zp.collectionsView.selectByID("P" + userLibraryID);
|
|
await waitForItemsLoad(win);
|
|
var iv = zp.itemsView;
|
|
|
|
var selected = iv.selectItem(item.id);
|
|
assert.ok(selected);
|
|
|
|
var promise = waitForDialog();
|
|
await zp.deleteSelectedItems(true);
|
|
await promise;
|
|
|
|
assert.isTrue(item.inPublications);
|
|
assert.isTrue(item.deleted);
|
|
});
|
|
|
|
it("should move saved search item to trash with prompt for unmodified Delete", async function () {
|
|
var search = await createDataObject('search');
|
|
var title = [...Object.values(search.conditions)]
|
|
.filter(x => x.condition == 'title' && x.operator == 'contains')[0].value;
|
|
var item = await createDataObject('item', { title });
|
|
|
|
await select(win, search);
|
|
var iv = zp.itemsView;
|
|
|
|
var selected = iv.selectItem(item.id);
|
|
assert.ok(selected);
|
|
|
|
var promise = waitForDialog();
|
|
await zp.deleteSelectedItems();
|
|
await promise;
|
|
|
|
assert.isTrue(item.deleted);
|
|
});
|
|
|
|
it("should move saved search item to trash without prompt for modified Delete", async function () {
|
|
var search = await createDataObject('search');
|
|
var title = [...Object.values(search.conditions)]
|
|
.filter(x => x.condition == 'title' && x.operator == 'contains')[0].value;
|
|
var item = await createDataObject('item', { title });
|
|
|
|
await select(win, search);
|
|
var iv = zp.itemsView;
|
|
|
|
var selected = iv.selectItem(item.id);
|
|
assert.ok(selected);
|
|
|
|
await zp.deleteSelectedItems(true);
|
|
|
|
assert.isTrue(item.deleted);
|
|
});
|
|
|
|
it("should prompt to remove an item from subcollections when recursiveCollections enabled", async function () {
|
|
Zotero.Prefs.set('recursiveCollections', true);
|
|
|
|
let collection1 = await createDataObject('collection');
|
|
let collection2 = await createDataObject('collection', { parentID: collection1.id });
|
|
let item = await createDataObject('item', { collections: [collection2.id] });
|
|
|
|
await select(win, collection1);
|
|
let iv = zp.itemsView;
|
|
assert.ok(await iv.selectItem(item.id));
|
|
|
|
await Zotero.Promise.delay(100);
|
|
|
|
let promise = waitForDialog();
|
|
let modifyPromise = waitForItemEvent('modify');
|
|
|
|
await zp.deleteSelectedItems(false);
|
|
|
|
let dialog = await promise;
|
|
await modifyPromise;
|
|
|
|
assert.include(dialog.document.documentElement.textContent, Zotero.getString('pane.items.removeRecursive'));
|
|
assert.isFalse(item.inCollection(collection2.id));
|
|
|
|
Zotero.Prefs.clear('recursiveCollections');
|
|
});
|
|
});
|
|
|
|
describe("#deleteSelectedCollection()", function () {
|
|
it("should move collection to trash but not descendant items by default", async function () {
|
|
var collection = await createDataObject('collection');
|
|
await select(win, collection);
|
|
var item = await createDataObject('item', { collections: [collection.id] });
|
|
var promise = waitForDialog();
|
|
await zp.deleteSelectedCollection();
|
|
assert.isTrue(collection.deleted);
|
|
assert.isTrue(Zotero.Items.exists(item.id));
|
|
assert.isFalse(item.deleted);
|
|
});
|
|
|
|
it("should move to trash collection and descendant items when deleteItems=true", async function () {
|
|
var collection = await createDataObject('collection');
|
|
await select(win, collection);
|
|
var item = await createDataObject('item', { collections: [collection.id] });
|
|
var promise = waitForDialog();
|
|
await zp.deleteSelectedCollection(true);
|
|
assert.isTrue(collection.deleted);
|
|
assert.isTrue(Zotero.Items.exists(item.id));
|
|
assert.isTrue(item.deleted);
|
|
});
|
|
});
|
|
|
|
|
|
describe("#emptyTrash()", function () {
|
|
it("should clear the undo/redo history", async function () {
|
|
// Record an undo entry
|
|
Zotero.UndoHistory.clear();
|
|
var collection = await createDataObject('collection', { name: 'Original' });
|
|
collection.name = 'Renamed';
|
|
await collection.saveTx({ undoAction: 'undo-action-rename-collection' });
|
|
assert.isTrue(Zotero.UndoHistory.canUndo());
|
|
|
|
// Put something in the trash to empty
|
|
await createDataObject('item', { deleted: true });
|
|
|
|
await selectTrash(win);
|
|
var promise = waitForDialog();
|
|
await zp.emptyTrash();
|
|
await promise;
|
|
|
|
assert.isFalse(Zotero.UndoHistory.canUndo());
|
|
assert.isFalse(Zotero.UndoHistory.canRedo());
|
|
});
|
|
});
|
|
|
|
|
|
describe("#setVirtual()", function () {
|
|
var cv;
|
|
|
|
before(function* () {
|
|
cv = zp.collectionsView;
|
|
});
|
|
beforeEach(function () {
|
|
Zotero.Prefs.clear('duplicateLibraries');
|
|
Zotero.Prefs.clear('unfiledLibraries');
|
|
return selectLibrary(win);
|
|
})
|
|
|
|
it("should show a hidden virtual collection in My Library", async function () {
|
|
// Create unfiled, duplicate items
|
|
var title = Zotero.Utilities.randomString();
|
|
var item1 = await createDataObject('item', { title });
|
|
var item2 = await createDataObject('item', { title });
|
|
|
|
// Start hidden (tested in collectionTreeViewTest)
|
|
Zotero.Prefs.set('duplicateLibraries', `{"${userLibraryID}": false}`);
|
|
Zotero.Prefs.set('unfiledLibraries', `{"${userLibraryID}": false}`);
|
|
await cv.refresh();
|
|
|
|
// Show Duplicate Items
|
|
var id = "D" + userLibraryID;
|
|
assert.isFalse(cv.getRowIndexByID(id));
|
|
await zp.setVirtual(userLibraryID, 'duplicates', true, true);
|
|
// Duplicate Items should be selected
|
|
assert.equal(zp.getCollectionTreeRows()[0].id, id);
|
|
// Should be missing from pref
|
|
assert.isUndefined(JSON.parse(Zotero.Prefs.get('duplicateLibraries'))[userLibraryID])
|
|
|
|
// Clicking should select both items
|
|
var row = cv.getRowIndexByID(id);
|
|
assert.ok(row);
|
|
assert.equal(cv.selection.pivot, row);
|
|
await waitForItemsLoad(win);
|
|
var iv = zp.itemsView;
|
|
row = iv.getRowIndexByID(item1.id);
|
|
assert.isNumber(row);
|
|
var promise = iv.waitForSelect();
|
|
clickOnItemsRow(win, iv, row);
|
|
assert.equal(iv.selection.count, 2);
|
|
await promise;
|
|
|
|
// Show Unfiled Items
|
|
id = "U" + userLibraryID;
|
|
assert.isFalse(cv.getRowIndexByID(id));
|
|
await zp.setVirtual(userLibraryID, 'unfiled', true, true);
|
|
// Unfiled Items should be selected
|
|
assert.equal(zp.getCollectionTreeRows()[0].id, id);
|
|
// Should be missing from pref
|
|
assert.isUndefined(JSON.parse(Zotero.Prefs.get('unfiledLibraries'))[userLibraryID])
|
|
});
|
|
|
|
it("should expand library if collapsed when showing virtual collection", async function () {
|
|
// Start hidden (tested in collectionTreeViewTest)
|
|
Zotero.Prefs.set('duplicateLibraries', `{"${userLibraryID}": false}`);
|
|
await cv.refresh();
|
|
|
|
var libraryRow = cv.getRowIndexByID(Zotero.Libraries.userLibrary.treeViewID);
|
|
if (cv.isContainerOpen(libraryRow)) {
|
|
await cv.toggleOpenState(libraryRow);
|
|
cv._saveOpenStates();
|
|
}
|
|
|
|
// Show Duplicate Items
|
|
var id = "D" + userLibraryID;
|
|
await zp.setVirtual(userLibraryID, 'duplicates', true, true);
|
|
|
|
// Library should have been expanded and Duplicate Items selected
|
|
assert.ok(cv.getRowIndexByID(id));
|
|
assert.equal(zp.getCollectionTreeRows()[0].id, id);
|
|
});
|
|
|
|
it("should hide a virtual collection in My Library", async function () {
|
|
await cv.refresh();
|
|
|
|
// Hide Duplicate Items
|
|
var id = "D" + userLibraryID;
|
|
assert.ok(await cv.selectByID(id));
|
|
await zp.setVirtual(userLibraryID, 'duplicates', false);
|
|
assert.isFalse(cv.getRowIndexByID(id));
|
|
assert.isFalse(JSON.parse(Zotero.Prefs.get('duplicateLibraries'))[userLibraryID])
|
|
|
|
// Hide Unfiled Items
|
|
id = "U" + userLibraryID;
|
|
assert.ok(await cv.selectByID(id));
|
|
await zp.setVirtual(userLibraryID, 'unfiled', false);
|
|
assert.isFalse(cv.getRowIndexByID(id));
|
|
assert.isFalse(JSON.parse(Zotero.Prefs.get('unfiledLibraries'))[userLibraryID])
|
|
});
|
|
|
|
it("should hide a virtual collection in a group", async function () {
|
|
await cv.refresh();
|
|
|
|
var group = await createGroup();
|
|
var groupRow = cv.getRowIndexByID(group.treeViewID);
|
|
var rowCount = cv._rows.length;
|
|
|
|
// Make sure group is open
|
|
if (!cv.isContainerOpen(groupRow)) {
|
|
await cv.toggleOpenState(groupRow);
|
|
}
|
|
|
|
// Make sure Duplicate Items is showing
|
|
var id = "D" + group.libraryID;
|
|
assert.ok(cv.getRowIndexByID(id));
|
|
|
|
// Hide Duplicate Items
|
|
assert.ok(await cv.selectByID(id));
|
|
await zp.setVirtual(group.libraryID, 'duplicates', false);
|
|
// Row should have been removed
|
|
assert.isFalse(cv.getRowIndexByID(id));
|
|
// Pref should have been updated
|
|
Zotero.debug(Zotero.Prefs.get('duplicateLibraries'));
|
|
assert.isFalse(JSON.parse(Zotero.Prefs.get('duplicateLibraries'))[group.libraryID]);
|
|
// Group row shouldn't have changed
|
|
assert.equal(cv.getRowIndexByID(group.treeViewID), groupRow);
|
|
// Group should remain open
|
|
assert.isTrue(cv.isContainerOpen(groupRow));
|
|
// Row count should be 1 less
|
|
assert.equal(cv._rows.length, --rowCount);
|
|
|
|
// Hide Unfiled Items
|
|
id = "U" + group.libraryID;
|
|
assert.ok(await cv.selectByID(id));
|
|
// Hide Unfiled Items
|
|
await zp.setVirtual(group.libraryID, 'unfiled', false);
|
|
// Row should have been removed
|
|
assert.isFalse(cv.getRowIndexByID(id));
|
|
// Pref should have been updated
|
|
assert.isFalse(JSON.parse(Zotero.Prefs.get('unfiledLibraries'))[group.libraryID]);
|
|
// Group row shouldn't have changed
|
|
assert.equal(cv.getRowIndexByID(group.treeViewID), groupRow);
|
|
// Group should remain open
|
|
assert.isTrue(cv.isContainerOpen(groupRow));
|
|
// Row count should be 1 less
|
|
assert.equal(cv._rows.length, --rowCount);
|
|
});
|
|
});
|
|
|
|
describe("#editSelectedCollection()", function () {
|
|
async function editSearchAddCondition(search) {
|
|
await select(win, search);
|
|
await zp.editSelectedCollection();
|
|
|
|
var deck = doc.getElementById('zotero-advanced-search-pane-deck');
|
|
assert.equal(deck.state, 'open');
|
|
assert.equal(deck.selectedSearchType, 'saved');
|
|
|
|
var pane = deck.pane;
|
|
var searchBox = pane.querySelector('zoterosearch');
|
|
var c = searchBox.search.getCondition(
|
|
searchBox.search.addCondition("title", "contains", "foo")
|
|
);
|
|
searchBox.rootGroup.addCondition(c);
|
|
await pane.save();
|
|
}
|
|
|
|
it("should edit a saved search", async function () {
|
|
var search = await createDataObject('search');
|
|
await editSearchAddCondition(search);
|
|
var conditions = search.getConditions();
|
|
assert.lengthOf(Object.keys(conditions), 3);
|
|
});
|
|
|
|
it("should edit a saved search in a group", async function () {
|
|
var group = await getGroup();
|
|
var search = await createDataObject('search', { libraryID: group.libraryID });
|
|
await editSearchAddCondition(search);
|
|
var conditions = search.getConditions();
|
|
assert.lengthOf(Object.keys(conditions), 3);
|
|
});
|
|
});
|
|
|
|
describe("#buildCollectionContextMenu()", function () {
|
|
async function selectCollectionRows(ids) {
|
|
let cv = zp.collectionsView;
|
|
await cv.selectByID(ids[0]);
|
|
await waitForItemsLoad(win);
|
|
for (let id of ids.slice(1)) {
|
|
cv.selection.toggleSelect(cv.getRowIndexByID(id));
|
|
}
|
|
await zp.onCollectionSelected();
|
|
await zp.itemsView.waitForLoad();
|
|
}
|
|
|
|
afterEach(async function () {
|
|
await selectLibrary(win);
|
|
});
|
|
|
|
it("should hide New Subcollection and Rename for a multiple-collection selection", async function () {
|
|
let c1 = await createDataObject('collection');
|
|
let c2 = await createDataObject('collection');
|
|
|
|
// A single collection shows both single-target actions
|
|
await selectCollectionRows(["C" + c1.id]);
|
|
await zp.buildCollectionContextMenu();
|
|
assert.isFalse(win.document.getElementById('newSubcollection').hidden);
|
|
assert.isFalse(win.document.getElementById('editSelectedCollection').hidden);
|
|
|
|
// Two collections hides them
|
|
await selectCollectionRows(["C" + c1.id, "C" + c2.id]);
|
|
await zp.buildCollectionContextMenu();
|
|
assert.isTrue(win.document.getElementById('newSubcollection').hidden);
|
|
assert.isTrue(win.document.getElementById('editSelectedCollection').hidden);
|
|
});
|
|
|
|
it("should disable the report for a cross-library collection selection", async function () {
|
|
let group = await createGroup();
|
|
let c1 = await createDataObject('collection');
|
|
let c2 = await createDataObject('collection', { libraryID: group.libraryID });
|
|
await createDataObject('item', { collections: [c1.id] });
|
|
await createDataObject('item', { libraryID: group.libraryID, collections: [c2.id] });
|
|
|
|
await zp.collectionsView.expandLibrary(group.libraryID);
|
|
await selectCollectionRows(["C" + c1.id, "C" + c2.id]);
|
|
await zp.buildCollectionContextMenu();
|
|
assert.isTrue(win.document.getElementById('loadReport').disabled);
|
|
|
|
await group.eraseTx();
|
|
});
|
|
});
|
|
|
|
describe("#buildAddItemToCollectionMenu()", function () {
|
|
var popup;
|
|
|
|
before(function () {
|
|
popup = doc.getElementById('zotero-add-to-collection-popup');
|
|
});
|
|
|
|
beforeEach(async function () {
|
|
// Leave the tree on the library root, so that a collection created later in a test
|
|
// can't be recorded by the reselection that follows adding a row
|
|
await selectLibrary(win);
|
|
});
|
|
|
|
after(async function () {
|
|
// Don't leave a multiple selection behind for later tests
|
|
await selectLibrary(win);
|
|
});
|
|
|
|
it("should record the selected collection as recently used", async function () {
|
|
var collection = await createDataObject('collection');
|
|
await zp.collectionsView.selectByID("C" + collection.id);
|
|
await waitForItemsLoad(win);
|
|
|
|
assert.equal(Zotero.Collections.getRecent()[0], collection);
|
|
});
|
|
|
|
it("should record the collection added to a multiple selection", async function () {
|
|
var c1 = await createDataObject('collection', { name: 'AAA recent' });
|
|
var c2 = await createDataObject('collection', { name: 'ZZZ recent' });
|
|
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();
|
|
|
|
assert.equal(Zotero.Collections.getRecent()[0], c2);
|
|
});
|
|
|
|
it("should offer recently used collections above the full list", async function () {
|
|
var parent = await createDataObject('collection', { name: 'Parent' });
|
|
var child = await createDataObject('collection', { name: 'Child', parentID: parent.id });
|
|
var item = await createDataObject('item', { collections: [parent.id] });
|
|
|
|
Zotero.Collections.addToRecent(parent);
|
|
Zotero.Collections.addToRecent(child);
|
|
|
|
zp.buildAddItemToCollectionMenu({ target: popup, currentTarget: popup }, [item]);
|
|
|
|
// Recent collections follow New Collection and a separator, by full path
|
|
var nodes = [...popup.children];
|
|
var recent = [];
|
|
for (let node of nodes.slice(nodes.findIndex(n => n.tagName == 'menuseparator') + 1)) {
|
|
if (node.tagName == 'menuseparator') break;
|
|
recent.push(node.getAttribute('label'));
|
|
}
|
|
assert.include(recent, 'Parent \u203A Child');
|
|
// The parent already contains the item, so it isn't offered
|
|
assert.notInclude(recent, 'Parent');
|
|
});
|
|
});
|
|
|
|
describe("#buildItemContextMenu()", function () {
|
|
it("shouldn't show export or bib options for multiple standalone file attachments without notes", async function () {
|
|
var item1 = await importFileAttachment('test.png');
|
|
var item2 = await importFileAttachment('test.png');
|
|
|
|
await zp.selectItems([item1.id, item2.id]);
|
|
await zp.buildItemContextMenu();
|
|
|
|
var menu = win.document.getElementById('zotero-itemmenu');
|
|
assert.isTrue(menu.querySelector('.zotero-menuitem-export').hidden);
|
|
assert.isTrue(menu.querySelector('.zotero-menuitem-create-bibliography').hidden);
|
|
});
|
|
|
|
it("should disable the report when selected items span libraries", async function () {
|
|
let group = await createGroup();
|
|
let c1 = await createDataObject('collection');
|
|
let c2 = await createDataObject('collection', { libraryID: group.libraryID });
|
|
let item1 = await createDataObject('item', { collections: [c1.id] });
|
|
let item2 = await createDataObject('item', { libraryID: group.libraryID, collections: [c2.id] });
|
|
|
|
// Cross-library multiple-collection selection -> grouped items view
|
|
await zp.collectionsView.expandLibrary(group.libraryID);
|
|
let 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();
|
|
|
|
let menu = win.document.getElementById('zotero-itemmenu');
|
|
let report = menu.querySelector('.zotero-menuitem-create-report');
|
|
|
|
// Select within the current (grouped) view rather than ZoteroPane.selectItems(),
|
|
// which would navigate the collection tree and drop the multi-selection
|
|
|
|
// Items from a single library: report enabled
|
|
await zp.itemsView.selectItems([item1.id]);
|
|
await zp.buildItemContextMenu();
|
|
assert.isFalse(report.disabled);
|
|
|
|
// Items spanning libraries: report disabled
|
|
await zp.itemsView.selectItems([item1.id, item2.id]);
|
|
await zp.buildItemContextMenu();
|
|
assert.isTrue(report.disabled);
|
|
|
|
await selectLibrary(win);
|
|
await group.eraseTx();
|
|
});
|
|
|
|
it("shouldn't offer Remove from Collection when a saved search is also selected", async function () {
|
|
let collection = await createDataObject('collection');
|
|
let search = await createDataObject('search');
|
|
let item = await createDataObject('item', { collections: [collection.id] });
|
|
|
|
let cv = zp.collectionsView;
|
|
let menu = win.document.getElementById('zotero-itemmenu');
|
|
let removeItems = menu.querySelector('.zotero-menuitem-remove-items');
|
|
let moveToTrash = menu.querySelector('.zotero-menuitem-move-to-trash');
|
|
|
|
await cv.selectByID("C" + collection.id);
|
|
await waitForItemsLoad(win);
|
|
await zp.itemsView.selectItems([item.id]);
|
|
await zp.buildItemContextMenu();
|
|
assert.isFalse(removeItems.hidden, "Offered for a collection on its own");
|
|
|
|
// Items in the saved search needn't be in the collection, so removing
|
|
// from the collection isn't meaningful
|
|
cv.selection.toggleSelect(cv.getRowIndexByID("S" + search.id));
|
|
await zp.onCollectionSelected();
|
|
await zp.itemsView.waitForLoad();
|
|
await zp.itemsView.selectItems([item.id]);
|
|
await zp.buildItemContextMenu();
|
|
assert.isTrue(removeItems.hidden, "Not offered alongside a saved search");
|
|
assert.isFalse(moveToTrash.hidden, "Move to Trash is still offered");
|
|
|
|
await selectLibrary(win);
|
|
});
|
|
|
|
it("shouldn't open the item context menu on a library header row", async function () {
|
|
let group = await createGroup();
|
|
let c1 = await createDataObject('collection');
|
|
let c2 = await createDataObject('collection', { libraryID: group.libraryID });
|
|
await createDataObject('item', { collections: [c1.id] });
|
|
await createDataObject('item', { libraryID: group.libraryID, collections: [c2.id] });
|
|
|
|
// Cross-library multiple-collection selection -> grouped view with headers
|
|
await zp.collectionsView.expandLibrary(group.libraryID);
|
|
let 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();
|
|
|
|
let headerRow = zp.itemsView.getRowIndexByID("L" + Zotero.Libraries.userLibraryID);
|
|
let headerNode = win.document.getElementById(`${zp.itemsView.id}-row-${headerRow}`);
|
|
assert.isTrue(headerNode.classList.contains('library-header-row'));
|
|
|
|
let spy = sinon.spy(zp, 'buildItemContextMenu');
|
|
try {
|
|
await zp.onItemsContextMenuOpen({ target: headerNode, screenX: 0, screenY: 0 });
|
|
assert.isFalse(spy.called, "Context menu shouldn't be built for a header row");
|
|
}
|
|
finally {
|
|
spy.restore();
|
|
}
|
|
|
|
await selectLibrary(win);
|
|
await group.eraseTx();
|
|
});
|
|
|
|
it("shouldn't activate a library header row on double-click", async function () {
|
|
let group = await createGroup();
|
|
let c1 = await createDataObject('collection');
|
|
let c2 = await createDataObject('collection', { libraryID: group.libraryID });
|
|
await createDataObject('item', { collections: [c1.id] });
|
|
await createDataObject('item', { libraryID: group.libraryID, collections: [c2.id] });
|
|
|
|
// Cross-library multiple-collection selection -> grouped view with headers
|
|
await zp.collectionsView.expandLibrary(group.libraryID);
|
|
let 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();
|
|
|
|
let headerRow = zp.itemsView.getRowIndexByID("L" + Zotero.Libraries.userLibraryID);
|
|
assert.equal(zp.itemsView.getRow(headerRow).type, 'library-header');
|
|
|
|
let spy = sinon.spy(zp, 'onItemTreeActivate');
|
|
try {
|
|
zp.itemsView.handleActivate(new MouseEvent('dblclick'), [headerRow]);
|
|
assert.isFalse(spy.called, "Header row shouldn't be activated");
|
|
}
|
|
finally {
|
|
spy.restore();
|
|
}
|
|
|
|
await selectLibrary(win);
|
|
await group.eraseTx();
|
|
});
|
|
|
|
it("should show “Export Note…” for standalone file attachment with note", async function () {
|
|
var item1 = await importFileAttachment('test.png');
|
|
item1.setNote('<p>Foo</p>');
|
|
await item1.saveTx();
|
|
var item2 = await importFileAttachment('test.png');
|
|
|
|
await zp.selectItems([item1.id, item2.id]);
|
|
await zp.buildItemContextMenu();
|
|
|
|
var menu = win.document.getElementById('zotero-itemmenu');
|
|
var exportMenuItem = menu.querySelector('.zotero-menuitem-export');
|
|
assert.isFalse(exportMenuItem.hidden);
|
|
assert.equal(
|
|
exportMenuItem.getAttribute('label'),
|
|
Zotero.getString('pane.items.menu.exportNote.multiple')
|
|
);
|
|
});
|
|
|
|
it("should enable “Delete Item…” when selected item or an ancestor is in trash", async function () {
|
|
var item1 = await createDataObject('item', { deleted: true });
|
|
var attachment1 = await importFileAttachment('test.png', { parentItemID: item1.id });
|
|
|
|
var userLibraryID = Zotero.Libraries.userLibraryID;
|
|
await zp.collectionsView.selectByID('T' + userLibraryID);
|
|
|
|
// Enable for attachment of item in trash
|
|
await zp.selectItems([attachment1.id]);
|
|
await zp.buildItemContextMenu();
|
|
var menu = win.document.getElementById('zotero-itemmenu');
|
|
var deleteMenuItem = menu.querySelector('.zotero-menuitem-delete-from-lib');
|
|
assert.isFalse(deleteMenuItem.disabled);
|
|
|
|
// Enable for parent and attachment
|
|
await zp.selectItems([item1.id, attachment1.id]);
|
|
await zp.buildItemContextMenu();
|
|
assert.isFalse(deleteMenuItem.disabled);
|
|
|
|
// Disable for non-trashed parent shown as container of trashed attachment
|
|
item1.deleted = false;
|
|
attachment1.deleted = true;
|
|
await item1.saveTx();
|
|
await attachment1.saveTx();
|
|
await zp.selectItems([item1.id]);
|
|
await zp.buildItemContextMenu();
|
|
assert.isTrue(deleteMenuItem.disabled);
|
|
});
|
|
|
|
it("should enable “Restore to Library” when at least one selected item is in trash", async function () {
|
|
var item1 = await createDataObject('item', { deleted: true });
|
|
var attachment1 = await importFileAttachment('test.png', { parentItemID: item1.id });
|
|
|
|
var userLibraryID = Zotero.Libraries.userLibraryID;
|
|
await zp.collectionsView.selectByID('T' + userLibraryID);
|
|
|
|
await zp.selectItems([item1.id]);
|
|
await zp.buildItemContextMenu();
|
|
var menu = win.document.getElementById('zotero-itemmenu');
|
|
var restoreMenuItem = menu.querySelector('.zotero-menuitem-restore-to-library');
|
|
assert.isFalse(restoreMenuItem.disabled);
|
|
|
|
await zp.selectItems([item1.id, attachment1.id]);
|
|
await zp.buildItemContextMenu();
|
|
assert.isFalse(restoreMenuItem.disabled);
|
|
});
|
|
|
|
it("should disable “Restore to Library” when no selected items are in trash", async function () {
|
|
var item1 = await createDataObject('item');
|
|
var attachment1 = await importFileAttachment('test.png', { parentItemID: item1.id });
|
|
attachment1.deleted = true;
|
|
await attachment1.saveTx();
|
|
|
|
var userLibraryID = Zotero.Libraries.userLibraryID;
|
|
await zp.collectionsView.selectByID('T' + userLibraryID);
|
|
|
|
await zp.selectItems([item1.id]);
|
|
await zp.buildItemContextMenu();
|
|
var menu = win.document.getElementById('zotero-itemmenu');
|
|
var restoreMenuItem = menu.querySelector('.zotero-menuitem-restore-to-library');
|
|
assert.isTrue(restoreMenuItem.disabled);
|
|
});
|
|
});
|
|
|
|
describe("#restoreSelectedItems()", function () {
|
|
it("should restore trashed parent and single trashed child when both are selected", async function () {
|
|
let item1 = await createDataObject('item', { deleted: true });
|
|
let attachment1 = await importFileAttachment('test.png', { parentItemID: item1.id });
|
|
attachment1.deleted = true;
|
|
await attachment1.saveTx();
|
|
|
|
var userLibraryID = Zotero.Libraries.userLibraryID;
|
|
await zp.collectionsView.selectByID('T' + userLibraryID);
|
|
await zp.selectItems([item1.id, attachment1.id]);
|
|
await zp.restoreSelectedItems();
|
|
|
|
assert.isFalse(item1.deleted);
|
|
assert.isFalse(attachment1.deleted);
|
|
});
|
|
|
|
it("should restore child when parent and trashed child are selected", async function () {
|
|
let item1 = await createDataObject('item', { deleted: false });
|
|
let attachment1 = await importFileAttachment('test.png', { parentItemID: item1.id });
|
|
attachment1.deleted = true;
|
|
await attachment1.saveTx();
|
|
|
|
var userLibraryID = Zotero.Libraries.userLibraryID;
|
|
await zp.collectionsView.selectByID('T' + userLibraryID);
|
|
await zp.selectItems([item1.id, attachment1.id]);
|
|
await zp.restoreSelectedItems();
|
|
|
|
assert.isFalse(item1.deleted);
|
|
assert.isFalse(attachment1.deleted);
|
|
});
|
|
|
|
it("should restore parent and selected children when parent and some trashed children are selected", async function () {
|
|
let item1 = await createDataObject('item', { deleted: false });
|
|
let attachment1 = await importFileAttachment('test.png', { parentItemID: item1.id });
|
|
let attachment2 = await importFileAttachment('test.png', { parentItemID: item1.id });
|
|
attachment1.deleted = true;
|
|
await attachment1.saveTx();
|
|
attachment2.deleted = true;
|
|
await attachment2.saveTx();
|
|
|
|
var userLibraryID = Zotero.Libraries.userLibraryID;
|
|
await zp.collectionsView.selectByID('T' + userLibraryID);
|
|
await zp.selectItems([item1.id, attachment1.id]);
|
|
await zp.restoreSelectedItems();
|
|
|
|
assert.isFalse(item1.deleted);
|
|
assert.isFalse(attachment1.deleted);
|
|
assert.isTrue(attachment2.deleted);
|
|
});
|
|
|
|
it("should restore parent and all children when trashed parent and no children are selected", async function () {
|
|
let item1 = await createDataObject('item', { deleted: true });
|
|
let attachment1 = await importFileAttachment('test.png', { parentItemID: item1.id });
|
|
let attachment2 = await importFileAttachment('test.png', { parentItemID: item1.id });
|
|
let attachment3 = await importFileAttachment('test.png', { parentItemID: item1.id });
|
|
attachment1.deleted = true;
|
|
await attachment1.saveTx();
|
|
attachment2.deleted = true;
|
|
await attachment2.saveTx();
|
|
attachment3.deleted = true;
|
|
await attachment3.saveTx();
|
|
|
|
var userLibraryID = Zotero.Libraries.userLibraryID;
|
|
await zp.collectionsView.selectByID('T' + userLibraryID);
|
|
await zp.selectItems([item1.id]);
|
|
await zp.restoreSelectedItems();
|
|
|
|
assert.isFalse(item1.deleted);
|
|
assert.isFalse(attachment1.deleted);
|
|
assert.isFalse(attachment2.deleted);
|
|
assert.isFalse(attachment3.deleted);
|
|
});
|
|
|
|
it("should restore parent and selected children when trashed parent and some trashed children are selected", async function () {
|
|
let item1 = await createDataObject('item', { deleted: true });
|
|
let attachment1 = await importFileAttachment('test.png', { parentItemID: item1.id });
|
|
let attachment2 = await importFileAttachment('test.png', { parentItemID: item1.id });
|
|
let attachment3 = await importFileAttachment('test.png', { parentItemID: item1.id });
|
|
attachment1.deleted = true;
|
|
await attachment1.saveTx();
|
|
attachment2.deleted = true;
|
|
await attachment2.saveTx();
|
|
|
|
var userLibraryID = Zotero.Libraries.userLibraryID;
|
|
await zp.collectionsView.selectByID('T' + userLibraryID);
|
|
await zp.selectItems([item1.id, attachment2.id, attachment3.id]);
|
|
await zp.restoreSelectedItems();
|
|
|
|
assert.isFalse(item1.deleted);
|
|
assert.isTrue(attachment1.deleted);
|
|
assert.isFalse(attachment2.deleted);
|
|
assert.isFalse(attachment3.deleted);
|
|
});
|
|
|
|
it("should restore selected children when trashed children and untrashed children are selected", async function () {
|
|
let item1 = await createDataObject('item', { deleted: false });
|
|
let attachment1 = await importFileAttachment('test.png', { parentItemID: item1.id });
|
|
let attachment2 = await importFileAttachment('test.png', { parentItemID: item1.id });
|
|
let attachment3 = await importFileAttachment('test.png', { parentItemID: item1.id });
|
|
attachment1.deleted = true;
|
|
await attachment1.saveTx();
|
|
attachment2.deleted = true;
|
|
await attachment2.saveTx();
|
|
|
|
var userLibraryID = Zotero.Libraries.userLibraryID;
|
|
await zp.collectionsView.selectByID('T' + userLibraryID);
|
|
await zp.selectItems([attachment1.id, attachment2.id, attachment3.id]);
|
|
await zp.restoreSelectedItems();
|
|
|
|
assert.isFalse(item1.deleted);
|
|
assert.isFalse(attachment1.deleted);
|
|
assert.isFalse(attachment2.deleted);
|
|
assert.isFalse(attachment3.deleted);
|
|
});
|
|
});
|
|
|
|
describe("#checkForLinkedFilesToRelink()", function () {
|
|
let labdDir;
|
|
|
|
this.beforeEach(async () => {
|
|
labdDir = await getTempDirectory();
|
|
Zotero.Prefs.set('baseAttachmentPath', labdDir);
|
|
Zotero.Prefs.set('saveRelativeAttachmentPath', true);
|
|
});
|
|
|
|
it("should detect and relink a single attachment", async function () {
|
|
let item = await createDataObject('item');
|
|
let file = getTestDataDirectory();
|
|
file.append('test.pdf');
|
|
let outsideStorageDir = await getTempDirectory();
|
|
let outsideFile = OS.Path.join(outsideStorageDir, 'test.pdf');
|
|
|
|
let labdFile = OS.Path.join(labdDir, 'test.pdf');
|
|
|
|
await OS.File.copy(file.path, outsideFile);
|
|
|
|
let attachment = await Zotero.Attachments.linkFromFile({
|
|
file: outsideFile,
|
|
parentItemID: item.id
|
|
});
|
|
|
|
assert.isTrue(await attachment.fileExists());
|
|
await OS.File.move(outsideFile, labdFile);
|
|
assert.isFalse(await attachment.fileExists());
|
|
|
|
let stub = sinon.stub(zp, 'showLinkedFileFoundAutomaticallyDialog')
|
|
.returns('one');
|
|
await zp.checkForLinkedFilesToRelink(attachment);
|
|
assert.ok(stub.calledOnce);
|
|
assert.ok(stub.calledWith(attachment, sinon.match.string, 0));
|
|
|
|
assert.isTrue(await attachment.fileExists());
|
|
assert.equal(attachment.getFilePath(), labdFile);
|
|
assert.equal(attachment.attachmentPath, 'attachments:test.pdf');
|
|
|
|
stub.restore();
|
|
});
|
|
|
|
it("should detect and relink multiple attachments when user chooses", async function () {
|
|
for (let choice of ['one', 'all']) {
|
|
let file1 = getTestDataDirectory();
|
|
file1.append('test.pdf');
|
|
let file2 = getTestDataDirectory();
|
|
file2.append('empty.pdf');
|
|
let outsideStorageDir = await getTempDirectory();
|
|
let outsideFile1 = OS.Path.join(outsideStorageDir, 'test.pdf');
|
|
let outsideFile2 = OS.Path.join(outsideStorageDir, 'empty.pdf');
|
|
|
|
let labdFile1 = OS.Path.join(labdDir, 'test.pdf');
|
|
let labdFile2 = OS.Path.join(labdDir, 'empty.pdf');
|
|
|
|
await OS.File.copy(file1.path, outsideFile1);
|
|
await OS.File.copy(file2.path, outsideFile2);
|
|
|
|
let attachment1 = await Zotero.Attachments.linkFromFile({ file: outsideFile1 });
|
|
let attachment2 = await Zotero.Attachments.linkFromFile({ file: outsideFile2 });
|
|
|
|
assert.isTrue(await attachment1.fileExists());
|
|
assert.isTrue(await attachment2.fileExists());
|
|
await OS.File.move(outsideFile1, labdFile1);
|
|
await OS.File.move(outsideFile2, labdFile2);
|
|
assert.isFalse(await attachment1.fileExists());
|
|
assert.isFalse(await attachment2.fileExists());
|
|
|
|
let stub = sinon.stub(zp, 'showLinkedFileFoundAutomaticallyDialog')
|
|
.returns(choice);
|
|
await zp.checkForLinkedFilesToRelink(attachment1);
|
|
assert.ok(stub.calledOnce);
|
|
assert.ok(stub.calledWith(attachment1, sinon.match.string, 1));
|
|
|
|
assert.isTrue(await attachment1.fileExists());
|
|
assert.equal(await attachment2.fileExists(), choice === 'all');
|
|
assert.equal(attachment1.getFilePath(), labdFile1);
|
|
assert.equal(attachment1.attachmentPath, 'attachments:test.pdf');
|
|
if (choice === 'all') {
|
|
assert.equal(attachment2.getFilePath(), labdFile2);
|
|
assert.equal(attachment2.attachmentPath, 'attachments:empty.pdf');
|
|
}
|
|
else {
|
|
assert.equal(attachment2.getFilePath(), outsideFile2);
|
|
}
|
|
|
|
stub.restore();
|
|
}
|
|
});
|
|
|
|
it("should use subdirectories of original path", async function () {
|
|
let file = getTestDataDirectory();
|
|
file.append('test.pdf');
|
|
let outsideStorageDir = OS.Path.join(await getTempDirectory(), 'subdir');
|
|
await OS.File.makeDir(outsideStorageDir);
|
|
let outsideFile = OS.Path.join(outsideStorageDir, 'test.pdf');
|
|
|
|
let labdSubdir = OS.Path.join(labdDir, 'subdir');
|
|
await OS.File.makeDir(labdSubdir);
|
|
let labdFile = OS.Path.join(labdSubdir, 'test.pdf');
|
|
|
|
await OS.File.copy(file.path, outsideFile);
|
|
|
|
let attachment = await Zotero.Attachments.linkFromFile({ file: outsideFile });
|
|
|
|
assert.isTrue(await attachment.fileExists());
|
|
await OS.File.move(outsideFile, labdFile);
|
|
assert.isFalse(await attachment.fileExists());
|
|
|
|
let dialogStub = sinon.stub(zp, 'showLinkedFileFoundAutomaticallyDialog')
|
|
.returns('one');
|
|
// No longer works with IOUtils
|
|
//let existsSpy = sinon.spy(IOUtils, 'exists');
|
|
await zp.checkForLinkedFilesToRelink(attachment);
|
|
assert.ok(dialogStub.calledOnce);
|
|
assert.ok(dialogStub.calledWith(attachment, sinon.match.string, 0));
|
|
//Zotero.debug(existsSpy.calledWith(OS.Path.join(labdSubdir, 'test.pdf')));
|
|
//assert.ok(existsSpy.calledWith(OS.Path.join(labdSubdir, 'test.pdf')));
|
|
//assert.notOk(existsSpy.calledWith(OS.Path.join(labdDir, 'test.pdf'))); // Should never get there
|
|
|
|
assert.isTrue(await attachment.fileExists());
|
|
assert.equal(attachment.getFilePath(), labdFile);
|
|
assert.equal(attachment.attachmentPath, 'attachments:subdir/test.pdf');
|
|
|
|
dialogStub.restore();
|
|
//existsSpy.restore();
|
|
});
|
|
|
|
it("should handle Windows paths", async function () {
|
|
let filenames = [['test.pdf'], ['empty.pdf'], ['search', 'baz.pdf']];
|
|
let labdFiles = [];
|
|
let attachments = [];
|
|
|
|
for (let parts of filenames) {
|
|
let file = getTestDataDirectory();
|
|
parts.forEach(part => file.append(part));
|
|
|
|
await OS.File.makeDir(OS.Path.join(labdDir, ...parts.slice(0, -1)));
|
|
let labdFile = OS.Path.join(labdDir, ...parts);
|
|
await OS.File.copy(file.path, labdFile);
|
|
labdFiles.push(labdFile);
|
|
|
|
let attachment = await Zotero.Attachments.linkFromFile({ file });
|
|
attachment.attachmentPath = `C:\\test\\${parts.join('\\')}`;
|
|
await attachment.saveTx();
|
|
attachments.push(attachment);
|
|
|
|
assert.isFalse(await attachment.fileExists());
|
|
}
|
|
|
|
let stub = sinon.stub(zp, 'showLinkedFileFoundAutomaticallyDialog')
|
|
.returns('all');
|
|
await zp.checkForLinkedFilesToRelink(attachments[0]);
|
|
assert.ok(stub.calledOnce);
|
|
assert.ok(stub.calledWith(attachments[0], sinon.match.string, filenames.length - 1));
|
|
|
|
for (let i = 0; i < filenames.length; i++) {
|
|
let attachment = attachments[i];
|
|
assert.isTrue(await attachment.fileExists());
|
|
assert.equal(attachment.getFilePath(), labdFiles[i]);
|
|
assert.equal(attachment.attachmentPath, 'attachments:' + OS.Path.join(...filenames[i]));
|
|
}
|
|
|
|
stub.restore();
|
|
});
|
|
});
|
|
|
|
describe("#focus()", function () {
|
|
before(async function () {
|
|
var collection = new Zotero.Collection;
|
|
collection.name = "Focus Test";
|
|
await collection.saveTx();
|
|
// Make sure there is a tag
|
|
var item = new Zotero.Item('newspaperArticle');
|
|
item.setCollections([collection.id]);
|
|
await item.setTags(["Tag"]);
|
|
await item.saveTx({
|
|
skipSelect: true
|
|
});
|
|
// Make sure there is more than one tab so that the tabs menu is focusable
|
|
if (win.Zotero_Tabs.numTabs == 1) {
|
|
let attachment = await importFileAttachment('test.pdf');
|
|
await attachment.saveTx();
|
|
await zp.viewAttachment(attachment.id);
|
|
win.Zotero_Tabs.select('zotero-pane');
|
|
}
|
|
await waitForItemsLoad(win);
|
|
await zp.collectionsView.selectLibrary(userLibraryID);
|
|
});
|
|
|
|
var tab = new KeyboardEvent('keydown', {
|
|
key: 'Tab',
|
|
shiftKey: false,
|
|
bubbles: true
|
|
});
|
|
|
|
var shiftTab = new KeyboardEvent('keydown', {
|
|
key: 'Tab',
|
|
shiftKey: true,
|
|
bubbles: true
|
|
});
|
|
|
|
var rightArrow = new KeyboardEvent('keydown', {
|
|
key: 'ArrowRight',
|
|
bubbles: true
|
|
});
|
|
var leftArrow = new KeyboardEvent('keydown', {
|
|
key: 'ArrowLeft',
|
|
bubbles: true
|
|
});
|
|
|
|
// Focus sequence for Zotero Pane
|
|
let sequence = [
|
|
"zotero-tb-search-textbox",
|
|
"zotero-tb-search-dropmarker",
|
|
"zotero-tb-add",
|
|
"tag-selector-actions",
|
|
"search-input",
|
|
"tag-selector-item",
|
|
"collection-tree",
|
|
"zotero-collections-search",
|
|
"zotero-tb-collection-add",
|
|
"zotero-tb-sync",
|
|
"zotero-tb-tabs-menu"
|
|
];
|
|
// The focus traversal relies on focus/blur events, which only fire when the
|
|
// window is active. It's normally active, but can lose activation --
|
|
// intermittently in CI, or when another app is focused during a local run --
|
|
// so restore it if necessary and report whether that worked.
|
|
async function activatePaneWindow() {
|
|
if (Services.focus.activeWindow !== win) {
|
|
win.focus();
|
|
await Zotero.Promise.delay(100);
|
|
}
|
|
return Services.focus.activeWindow === win;
|
|
}
|
|
|
|
beforeEach(async function () {
|
|
// Reset collection search field state
|
|
let collectionSearchField = doc.getElementById("zotero-collections-search");
|
|
let collectionSearchButton = doc.getElementById("zotero-tb-collections-search");
|
|
collectionSearchField.classList.remove("visible", "expanding");
|
|
collectionSearchField.setAttribute("disabled", true);
|
|
collectionSearchField.style.visibility = 'hidden';
|
|
collectionSearchField.style.removeProperty('max-width');
|
|
collectionSearchField.value = '';
|
|
collectionSearchButton.style.display = '';
|
|
});
|
|
|
|
it("should shift-tab across the zotero pane", async function () {
|
|
// Without an active window, the traversal waits for events that never come
|
|
if (!await activatePaneWindow()) {
|
|
Zotero.debug("Skipping test -- pane window isn't active");
|
|
this.skip();
|
|
}
|
|
|
|
// Start from the Advanced Search button (the last focusable element in the
|
|
// search field) so the first shift-tab exercises advanced button -> search field
|
|
let advancedButton = doc.getElementById('zotero-tb-search-advanced-button');
|
|
advancedButton.focus();
|
|
|
|
for (let id of sequence) {
|
|
// Set up focus listener before dispatching the event
|
|
// to avoid racing against async focus from setTimeout.
|
|
// Use "focusin" rather than "focus" because the target is a XUL
|
|
// <search-textbox> whose real focus target is an anonymous inner
|
|
// <input>; "focus" does not bubble, so a listener on the outer
|
|
// element would never fire.
|
|
let focusPromise;
|
|
if (id === "zotero-collections-search") {
|
|
focusPromise = waitForDOMEvent(doc.getElementById(id), "focusin");
|
|
}
|
|
// Set up hide observer before the shift-tab that blurs collection-search
|
|
let hidePromise;
|
|
if (id === "zotero-tb-collection-add") {
|
|
let field = doc.getElementById("zotero-collections-search");
|
|
if (field.style.visibility !== 'hidden') {
|
|
hidePromise = waitForDOMAttributes(field, "style",
|
|
() => field.style.visibility === 'hidden');
|
|
}
|
|
}
|
|
doc.activeElement.dispatchEvent(shiftTab);
|
|
if (focusPromise) {
|
|
await focusPromise;
|
|
}
|
|
if (hidePromise) {
|
|
await hidePromise;
|
|
}
|
|
// Some elements don't have id, so use classes to verify they're focused
|
|
if (doc.activeElement.id) {
|
|
assert.equal(doc.activeElement.id, id);
|
|
}
|
|
else {
|
|
let clases = [...doc.activeElement.classList];
|
|
assert.include(clases, id);
|
|
}
|
|
}
|
|
doc.activeElement.dispatchEvent(shiftTab);
|
|
assert.equal(doc.activeElement.className, "tab selected");
|
|
|
|
doc.activeElement.dispatchEvent(shiftTab);
|
|
// Toggle Item/Context Pane button
|
|
assert.equal(doc.activeElement.dataset.action, "toggle-pane");
|
|
});
|
|
|
|
it("should tab across the zotero pane", async function () {
|
|
// Without an active window, the traversal waits for events that never come
|
|
if (!await activatePaneWindow()) {
|
|
Zotero.debug("Skipping test -- pane window isn't active");
|
|
this.skip();
|
|
}
|
|
|
|
win.Zotero_Tabs.moveFocus("current");
|
|
let reversed = [...sequence].reverse();
|
|
for (let id of reversed) {
|
|
// Set up focus listener before dispatching the event
|
|
// to avoid racing against async focus from setTimeout.
|
|
// See the shift-tab test above for why this listens on "focusin"
|
|
// rather than "focus".
|
|
let focusPromise;
|
|
if (id === "zotero-collections-search") {
|
|
focusPromise = waitForDOMEvent(doc.getElementById(id), "focusin");
|
|
}
|
|
doc.activeElement.dispatchEvent(tab);
|
|
if (focusPromise) {
|
|
await focusPromise;
|
|
}
|
|
// Some elements don't have id, so use classes to verify they're focused
|
|
if (doc.activeElement.id) {
|
|
assert.equal(doc.activeElement.id, id);
|
|
}
|
|
else {
|
|
let clases = [...doc.activeElement.classList];
|
|
assert.include(clases, id);
|
|
}
|
|
}
|
|
// Tab from the search field to the Advanced Search button at the end of the field
|
|
doc.activeElement.dispatchEvent(tab);
|
|
assert.equal(doc.activeElement.id, "zotero-tb-search-advanced-button");
|
|
});
|
|
|
|
it("should navigate toolbarbuttons with arrows", async function () {
|
|
let addItem = doc.getElementById('zotero-tb-add');
|
|
addItem.focus();
|
|
|
|
doc.activeElement.dispatchEvent(rightArrow);
|
|
assert.equal(doc.activeElement.id, "zotero-tb-lookup");
|
|
doc.activeElement.dispatchEvent(rightArrow);
|
|
assert.equal(doc.activeElement.id, "zotero-tb-attachment-add");
|
|
doc.activeElement.dispatchEvent(rightArrow);
|
|
assert.equal(doc.activeElement.id, "zotero-tb-note-add");
|
|
|
|
doc.activeElement.dispatchEvent(leftArrow);
|
|
assert.equal(doc.activeElement.id, "zotero-tb-attachment-add");
|
|
doc.activeElement.dispatchEvent(leftArrow);
|
|
assert.equal(doc.activeElement.id, "zotero-tb-lookup");
|
|
doc.activeElement.dispatchEvent(leftArrow);
|
|
assert.equal(doc.activeElement.id, "zotero-tb-add");
|
|
});
|
|
});
|
|
|
|
describe("#addAttachmentFromDialog()", function () {
|
|
it("should set an automatic title on the first file attachment of each supported type", async function () {
|
|
let parentItem = await createDataObject('item');
|
|
|
|
// Add a link attachment, which won't affect renaming
|
|
await Zotero.Attachments.linkFromURL({
|
|
url: 'https://example.com/',
|
|
parentItemID: parentItem.id,
|
|
});
|
|
|
|
// Add a PDF attachment, which will get a default title
|
|
let file = getTestDataDirectory();
|
|
file.append('test.pdf');
|
|
let [pdfAttachment1] = await zp.addAttachmentFromDialog(false, parentItem.id, [file.path]);
|
|
assert.equal(parentItem.getAttachments().length, 2);
|
|
assert.equal(pdfAttachment1.getField('title'), Zotero.getString('file-type-pdf'));
|
|
|
|
// Add a second, which will get a title based on its filename
|
|
let [pdfAttachment2] = await zp.addAttachmentFromDialog(false, parentItem.id, [file.path]);
|
|
assert.equal(parentItem.getAttachments().length, 3);
|
|
assert.equal(pdfAttachment2.getField('title'), 'test');
|
|
|
|
// Add an EPUB attachment, which will get a default title
|
|
file = getTestDataDirectory();
|
|
file.append('stub.epub');
|
|
let [epubAttachment] = await zp.addAttachmentFromDialog(false, parentItem.id, [file.path]);
|
|
assert.equal(parentItem.getAttachments().length, 4);
|
|
assert.equal(epubAttachment.getField('title'), Zotero.getString('file-type-ebook'));
|
|
});
|
|
|
|
it("shouldn't set type-based titles when multiple attachments of the same type are added at once", async function () {
|
|
let parentItem = await createDataObject('item');
|
|
|
|
// Add two PDF attachments at once, which will get titles based on their filenames
|
|
let file = getTestDataDirectory();
|
|
file.append('test.pdf');
|
|
let [pdfAttachment1, pdfAttachment2] = await zp.addAttachmentFromDialog(false, parentItem.id, [file.path, file.path]);
|
|
assert.equal(parentItem.getAttachments().length, 2);
|
|
assert.equal(pdfAttachment1.getField('title'), 'test');
|
|
assert.equal(pdfAttachment2.getField('title'), 'test');
|
|
|
|
// Add an EPUB attachment, which will get a default title
|
|
file = getTestDataDirectory();
|
|
file.append('stub.epub');
|
|
let [epubAttachment] = await zp.addAttachmentFromDialog(false, parentItem.id, [file.path]);
|
|
assert.equal(parentItem.getAttachments().length, 3);
|
|
assert.equal(epubAttachment.getField('title'), Zotero.getString('file-type-ebook'));
|
|
});
|
|
|
|
it("should select added file attachment", async function () {
|
|
let parentItem = await createDataObject('item');
|
|
|
|
let file = getTestDataDirectory();
|
|
file.append('test.pdf');
|
|
let [pdfAttachment1] = await zp.addAttachmentFromDialog(false, parentItem.id, [file.path]);
|
|
|
|
let parentItemIndex = zp.itemsView.getRowIndexByID(parentItem.id);
|
|
assert.equal(zp.itemsView.selection.focused, parentItemIndex + 1);
|
|
let selected = zp.itemsView.getSelectedItems()[0];
|
|
assert.equal(selected.id, pdfAttachment1.id);
|
|
});
|
|
|
|
describe("Linked file renaming", function () {
|
|
before(() => {
|
|
Zotero.Prefs.set('autoRenameFiles.linked', true);
|
|
});
|
|
|
|
after(() => {
|
|
Zotero.Prefs.clear('autoRenameFiles.linked');
|
|
});
|
|
|
|
it("should only rename and change the title of the first PDF attachment", async function () {
|
|
let testFile = getTestDataDirectory();
|
|
testFile.append('test.pdf');
|
|
|
|
let tempDir = await getTempDirectory();
|
|
let copy1 = PathUtils.join(tempDir, 'copy1.pdf');
|
|
let copy2 = PathUtils.join(tempDir, 'copy2.pdf');
|
|
|
|
await IOUtils.copy(testFile.path, copy1);
|
|
await IOUtils.copy(testFile.path, copy2);
|
|
|
|
let parentItem = await createDataObject('item', { title: 'Foo' });
|
|
|
|
let [attachment1] = await zp.addAttachmentFromDialog(false, parentItem.id, [copy1]);
|
|
assert.equal(attachment1.getField('title'), Zotero.getString('file-type-pdf'));
|
|
assert.equal(attachment1.attachmentFilename, 'Foo.pdf');
|
|
|
|
let [attachment2] = await zp.addAttachmentFromDialog(false, parentItem.id, [copy2]);
|
|
assert.equal(attachment2.getField('title'), 'copy2');
|
|
assert.equal(attachment2.attachmentFilename, 'copy2.pdf');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("#createParentItemsFromSelected()", function () {
|
|
async function createParent() {
|
|
let parent;
|
|
let dialogPromise = waitForDialog(async (win) => {
|
|
parent = await createDataObject('item', { title: 'Book Title' });
|
|
win.arguments[0].dataOut = { parent };
|
|
win.close();
|
|
}, false, 'chrome://zotero/content/createParentDialog.xhtml');
|
|
let createParentPromise = zp.createParentItemsFromSelected();
|
|
await dialogPromise;
|
|
await createParentPromise;
|
|
return parent;
|
|
}
|
|
|
|
it("should rename the attachment and set an automatic title", async function () {
|
|
let attachment = await importPDFAttachment({
|
|
title: 'Attachment title',
|
|
});
|
|
assert.equal(attachment.attachmentFilename, 'test.pdf');
|
|
|
|
let parent = await createParent();
|
|
assert.equal(attachment.parentItem, parent);
|
|
assert.equal(attachment.attachmentFilename, 'Book Title.pdf');
|
|
assert.equal(attachment.getField('title'), Zotero.getString('file-type-pdf'));
|
|
});
|
|
|
|
it("shouldn't rename or change the title of an attachment with a disabled type", async function () {
|
|
Zotero.Prefs.set('autoRenameFiles.fileTypes', 'x-nonexistent/type');
|
|
|
|
let file = getTestDataDirectory();
|
|
file.append('test.pdf');
|
|
let attachment = await Zotero.Attachments.linkFromFile({
|
|
file,
|
|
title: 'Attachment title'
|
|
});
|
|
assert.equal(attachment.attachmentFilename, 'test.pdf');
|
|
|
|
let parent = await createParent();
|
|
assert.equal(attachment.parentItem, parent);
|
|
assert.equal(attachment.attachmentFilename, 'test.pdf');
|
|
assert.equal(attachment.getField('title'), 'Attachment title');
|
|
|
|
Zotero.Prefs.clear('autoRenameFiles.fileTypes');
|
|
});
|
|
|
|
it("shouldn't rename a linked attachment or set an automatic title when linked file renaming disabled", async function () {
|
|
Zotero.Prefs.set('autoRenameFiles.linked', false);
|
|
|
|
let file = getTestDataDirectory();
|
|
file.append('test.pdf');
|
|
let attachment = await Zotero.Attachments.linkFromFile({
|
|
file,
|
|
title: 'Attachment title'
|
|
});
|
|
assert.equal(attachment.attachmentFilename, 'test.pdf');
|
|
|
|
let parent = await createParent();
|
|
assert.equal(attachment.parentItem, parent);
|
|
assert.equal(attachment.attachmentFilename, 'test.pdf');
|
|
assert.equal(attachment.getField('title'), 'Attachment title');
|
|
|
|
Zotero.Prefs.clear('autoRenameFiles.linked');
|
|
});
|
|
});
|
|
describe("#changeParentItem", function () {
|
|
it("should update the parent of selected items", async function () {
|
|
// One item has 2 children and the other one - none
|
|
let oldParent = await createDataObject('item');
|
|
let newParent = await createDataObject('item');
|
|
var attachment = await importPDFAttachment(oldParent);
|
|
var note = await createDataObject('item', { itemType: 'note', parentID: oldParent.id });
|
|
// Select child items
|
|
await zp.selectItems([attachment.id, note.id]);
|
|
// Open the dialog to select new parent and wait for it to load
|
|
waitForWindow('chrome://zotero/content/selectItemsDialog.xhtml', async (selectWin) => {
|
|
do {
|
|
await Zotero.Promise.delay(50);
|
|
}
|
|
while (!selectWin.loaded);
|
|
console.log(selectWin);
|
|
await selectWin.itemsView.waitForLoad();
|
|
// select item and accept the dialog
|
|
await selectWin.itemsView.selectItem(newParent.id);
|
|
selectWin.document.querySelector('dialog').acceptDialog();
|
|
});
|
|
zp.changeParentItem();
|
|
|
|
await waitForItemEvent('modify');
|
|
// Make sure the new parent now has the note and attachment
|
|
assert.include(newParent.getNotes(), note.id);
|
|
assert.include(newParent.getAttachments(), attachment.id);
|
|
// And the old parent does not
|
|
assert.notInclude(oldParent.getNotes(), note.id);
|
|
assert.notInclude(oldParent.getAttachments(), attachment.id);
|
|
});
|
|
|
|
it("should allow converting attachments to standalone when applicable", async function () {
|
|
let collection = await createDataObject('collection');
|
|
let parent = await createDataObject('item', { collections: [collection.id] });
|
|
var attachment = await importPDFAttachment(parent);
|
|
// Select child item
|
|
await zp.selectItems([attachment.id]);
|
|
// Open the dialog to select new parent and wait for it to load
|
|
waitForWindow('chrome://zotero/content/selectItemsDialog.xhtml', async (selectWin) => {
|
|
do {
|
|
await Zotero.Promise.delay(50);
|
|
}
|
|
while (!selectWin.loaded);
|
|
await selectWin.itemsView.waitForLoad();
|
|
// Button to make attachment standalone should be visible
|
|
let moveToStandaloneBtn = selectWin.document.querySelector("dialog button[dlgtype=extra1]");
|
|
assert.isFalse(moveToStandaloneBtn.hidden);
|
|
moveToStandaloneBtn.click();
|
|
});
|
|
zp.changeParentItem();
|
|
|
|
await waitForItemEvent('modify');
|
|
|
|
// The attachment should have no parent item
|
|
assert.isFalse(attachment.parentID);
|
|
// Attachment should belong to the same collection as parent item
|
|
assert.equal(attachment.getCollections()[0], collection.id);
|
|
});
|
|
});
|
|
|
|
describe("#copyCollection", function () {
|
|
it("should copy collection within the same library", async function () {
|
|
let collectionParent = await createDataObject('collection');
|
|
let collectionChild = await createDataObject('collection', { parentID: collectionParent.id });
|
|
let collectionDestination = await createDataObject('collection');
|
|
|
|
let itemOne = await createDataObject('item', { collections: [collectionParent.id] });
|
|
let itemTwo = await createDataObject('item', { collections: [collectionChild.id] });
|
|
|
|
await zp.collectionsView.selectByID("C" + collectionParent.id);
|
|
|
|
await zp.copyCollection(collectionDestination);
|
|
await waitForNotifierEvent("add", "collection");
|
|
|
|
// Newly created collections have the same names as the original ones
|
|
let collectionNames = collectionDestination.getDescendents(false, 'collection').map(col => col.name);
|
|
assert.sameMembers(collectionNames, [collectionParent.name, collectionChild.name]);
|
|
|
|
// Newly created collections contain the same items
|
|
let items = collectionDestination.getDescendents(false, 'item').map(item => item.id);
|
|
assert.sameMembers(items, [itemOne.id, itemTwo.id]);
|
|
});
|
|
|
|
it("should duplicate top-level collection", async function () {
|
|
let collection = await createDataObject('collection');
|
|
let mylibrary = Zotero.Libraries.get(collection.libraryID);
|
|
|
|
let itemOne = await createDataObject('item', { collections: [collection.id] });
|
|
|
|
await zp.collectionsView.selectByID("C" + collection.id);
|
|
|
|
await zp.copyCollection(mylibrary);
|
|
await waitForNotifierEvent("add", "collection");
|
|
|
|
// Find the duplicated collection and make sure it exists
|
|
let topLevelCollections = Zotero.Collections.getByLibrary(mylibrary.id);
|
|
let newCollection = topLevelCollections.find(col => col.name == collection.name && collection.id !== col.id);
|
|
assert.exists(newCollection);
|
|
|
|
// Newly created collection contain the same item
|
|
let items = newCollection.getDescendents(false, 'item').map(item => item.id);
|
|
assert.sameMembers(items, [itemOne.id]);
|
|
});
|
|
|
|
it("should copy collection between libraries", async function () {
|
|
let groupDestination = await createGroup();
|
|
let groupCollection = await createDataObject('collection', { libraryID: groupDestination.libraryID });
|
|
|
|
let collectionParent = await createDataObject('collection');
|
|
let collectionChild = await createDataObject('collection', { parentID: collectionParent.id });
|
|
|
|
let itemOne = await createDataObject('item', { collections: [collectionParent.id] });
|
|
let itemTwo = await createDataObject('item', { collections: [collectionChild.id] });
|
|
|
|
await zp.collectionsView.selectByID("C" + collectionParent.id);
|
|
|
|
await zp.copyCollection(groupCollection);
|
|
|
|
await waitForNotifierEvent("add", "collection");
|
|
|
|
// Newly created collections have the same names as the original ones
|
|
let collectionNames = groupCollection.getDescendents(false, 'collection').map(col => col.name);
|
|
assert.sameMembers(collectionNames, [collectionParent.name, collectionChild.name]);
|
|
|
|
// Newly created collections also have copies of items
|
|
let items = groupCollection.getDescendents(false, 'item').map(item => Zotero.Items.get(item.id).getDisplayTitle());
|
|
assert.sameMembers(items, [itemOne.getDisplayTitle(), itemTwo.getDisplayTitle()]);
|
|
});
|
|
|
|
it("should not allow copying between libraries if there is a linked collection", async function () {
|
|
let groupDestination = await createGroup();
|
|
let groupCollection = await createDataObject('collection', { libraryID: groupDestination.libraryID });
|
|
|
|
let collection = await createDataObject('collection');
|
|
let collectionChild = await createDataObject('collection', { parentID: collection.id });
|
|
|
|
await zp.collectionsView.selectByID("C" + collectionChild.id);
|
|
|
|
await zp.copyCollection(groupCollection);
|
|
|
|
await waitForNotifierEvent("add", "collection");
|
|
|
|
// Collection has been copies
|
|
let groupCollections = groupCollection.getDescendents(false, 'collection');
|
|
let newCollectionID = groupCollections.find(col => col.name == collectionChild.name).id;
|
|
let newCollection = Zotero.Collections.get(newCollectionID);
|
|
assert.exists(newCollection);
|
|
|
|
// Right click on the selected collection
|
|
await zp.collectionsView.selectByID("C" + collectionChild.id);
|
|
zp.buildCopyCollectionMenu({});
|
|
|
|
// Delay for menus to get disabled
|
|
await Zotero.Promise.delay();
|
|
let groupMenu = doc.querySelector(`#zotero-copy-collection-popup menu[value="L${groupDestination.libraryID}"]`);
|
|
// Menu of the library with linked collection should be disabled
|
|
assert.equal(groupMenu.disabled, true);
|
|
|
|
// Right click on the parent of the copies collection
|
|
await zp.collectionsView.selectByID("C" + collection.id);
|
|
zp.buildCopyCollectionMenu({});
|
|
// Delay for menus to get disabled
|
|
await Zotero.Promise.delay();
|
|
groupMenu = doc.querySelector(`#zotero-copy-collection-popup menu[value="L${groupDestination.libraryID}"]`);
|
|
// Menu of the library with linked sub-collection should be disabled
|
|
assert.equal(groupMenu.disabled, true);
|
|
});
|
|
|
|
it("should copy subcollection to library root", async function () {
|
|
let collectionParent = await createDataObject('collection');
|
|
let collectionChild = await createDataObject('collection', { parentID: collectionParent.id });
|
|
let libraryDestination = Zotero.Libraries.get(collectionChild.libraryID);
|
|
|
|
let item = await createDataObject('item', { collections: [collectionChild.id] });
|
|
|
|
await zp.collectionsView.selectByID("C" + collectionChild.id);
|
|
|
|
await zp.copyCollection(libraryDestination);
|
|
let data = await waitForNotifierEvent("add", "collection");
|
|
let collectionID = data.ids[0];
|
|
let newCollection = Zotero.Collections.get(collectionID);
|
|
|
|
// Copied collection has the same name as the original
|
|
assert.equal(newCollection.name, collectionChild.name);
|
|
// Copied collections contain the same item
|
|
let items = newCollection.getDescendents(false, 'item').map(item => item.id);
|
|
assert.sameMembers(items, [item.id]);
|
|
// Copied collection is a top-level collection
|
|
assert.notOk(newCollection.parentID);
|
|
});
|
|
});
|
|
describe("#moveCollection", function () {
|
|
it("should move collection into another collection of the same library", async function () {
|
|
let collection = await createDataObject('collection');
|
|
let collectionDestination = await createDataObject('collection');
|
|
|
|
await zp.collectionsView.selectByID("C" + collection.id);
|
|
|
|
let promise = waitForNotifierEvent("modify", "collection");
|
|
await zp.moveCollection(collectionDestination);
|
|
await promise;
|
|
|
|
// Collection was moved into destination collection
|
|
let collectionChildIDs = collectionDestination.getDescendents(false, 'collection').map(col => col.id);
|
|
assert.sameMembers(collectionChildIDs, [collection.id]);
|
|
});
|
|
it("should make collection a top-level collection", async function () {
|
|
let collectionParent = await createDataObject('collection');
|
|
let collectionChild = await createDataObject('collection', { parentID: collectionParent.id });
|
|
let library = Zotero.Libraries.get(collectionChild.libraryID);
|
|
|
|
await zp.collectionsView.selectByID("C" + collectionChild.id);
|
|
|
|
let promise = waitForNotifierEvent("modify", "collection");
|
|
await zp.moveCollection(library);
|
|
await promise;
|
|
|
|
// Child collection was pulled from under its parent to become a top-level collection
|
|
let topLevelCollections = Zotero.Collections.getByLibrary(library.id);
|
|
assert.includeMembers(topLevelCollections, [collectionChild]);
|
|
});
|
|
});
|
|
|
|
|
|
describe("Multi-Collection Selection", function () {
|
|
afterEach(async function () {
|
|
Zotero.Prefs.clear('recursiveCollections');
|
|
await selectLibrary(win);
|
|
});
|
|
|
|
async function selectMultipleCollections(collections) {
|
|
let cv = zp.collectionsView;
|
|
// Select the first collection
|
|
await cv.selectByID("C" + collections[0].id);
|
|
await waitForItemsLoad(win);
|
|
// Toggle-select additional collections
|
|
for (let i = 1; i < collections.length; i++) {
|
|
let row = cv.getRowIndexByID("C" + collections[i].id);
|
|
cv.selection.toggleSelect(row);
|
|
}
|
|
// Wait for the items view to reload with the combined set. The
|
|
// selection-triggered call is queued ahead of this one in Zotero.serial,
|
|
// and this one no-ops once the view matches the selection.
|
|
await zp.onCollectionSelected();
|
|
await zp.itemsView.waitForLoad();
|
|
}
|
|
|
|
describe("Uncombinable selections", function () {
|
|
it("should keep only the focused row when Recently Read and a collection are selected together", async function () {
|
|
let collection = await createDataObject('collection');
|
|
let cv = zp.collectionsView;
|
|
// Show and select Recently Read
|
|
await zp.setVirtual(Zotero.Libraries.userLibraryID, 'recentlyRead', true, true);
|
|
await waitForItemsLoad(win);
|
|
// Toggle-select the collection, forming an uncombinable two-row selection
|
|
cv.selection.toggleSelect(cv.getRowIndexByID("C" + collection.id));
|
|
await zp.onCollectionSelected();
|
|
|
|
// The selection should have been reduced to the focused (collection) row
|
|
assert.equal(cv.selection.count, 1);
|
|
let rows = zp.getCollectionTreeRows();
|
|
assert.lengthOf(rows, 1);
|
|
assert.isTrue(rows[0].isCollection());
|
|
|
|
await zp.setVirtual(Zotero.Libraries.userLibraryID, 'recentlyRead', false);
|
|
});
|
|
|
|
it("should keep only the focused row when a library and a collection are selected together", async function () {
|
|
let collection = await createDataObject('collection');
|
|
let cv = zp.collectionsView;
|
|
// Select the library root, then toggle-select a collection within it
|
|
await cv.selectByID("L" + Zotero.Libraries.userLibraryID);
|
|
await waitForItemsLoad(win);
|
|
cv.selection.toggleSelect(cv.getRowIndexByID("C" + collection.id));
|
|
await zp.onCollectionSelected();
|
|
|
|
// The selection should have been reduced to the focused (collection) row
|
|
assert.equal(cv.selection.count, 1);
|
|
let rows = zp.getCollectionTreeRows();
|
|
assert.lengthOf(rows, 1);
|
|
assert.isTrue(rows[0].isCollection());
|
|
});
|
|
});
|
|
|
|
describe("Combinable special views", function () {
|
|
it("should combine Recently Read across libraries without reducing the selection", async function () {
|
|
let userLibraryID = Zotero.Libraries.userLibraryID;
|
|
let groupLibraryID = (await createGroup()).libraryID;
|
|
let cv = zp.collectionsView;
|
|
|
|
// Seed a recently-read item in each library
|
|
let userItem = await createDataObject('item', { libraryID: userLibraryID });
|
|
let userAttachment = await importPDFAttachment(userItem);
|
|
userAttachment.attachmentLastRead = Math.round(Date.now() / 1000);
|
|
await userAttachment.saveTx();
|
|
let groupItem = await createDataObject('item', { libraryID: groupLibraryID });
|
|
let groupAttachment = await importPDFAttachment(groupItem);
|
|
groupAttachment.attachmentLastRead = Math.round(Date.now() / 1000);
|
|
await groupAttachment.saveTx();
|
|
|
|
// Show Recently Read in both libraries, then select both rows
|
|
await zp.setVirtual(userLibraryID, 'recentlyRead', true, false);
|
|
await zp.setVirtual(groupLibraryID, 'recentlyRead', true, false);
|
|
await cv.selectByID('Y' + userLibraryID);
|
|
await waitForItemsLoad(win);
|
|
cv.selection.toggleSelect(cv.getRowIndexByID('Y' + groupLibraryID));
|
|
await zp.onCollectionSelected();
|
|
await zp.itemsView.waitForLoad();
|
|
|
|
// Both Recently Read rows should remain selected
|
|
assert.equal(cv.selection.count, 2);
|
|
let rows = zp.getCollectionTreeRows();
|
|
assert.lengthOf(rows, 2);
|
|
assert.isTrue(rows.every(r => r.isRecentlyRead()));
|
|
|
|
// The items list should show the recently-read item from each library
|
|
assert.isNumber(zp.itemsView.getRowIndexByID(userItem.id));
|
|
assert.isNumber(zp.itemsView.getRowIndexByID(groupItem.id));
|
|
|
|
await zp.setVirtual(userLibraryID, 'recentlyRead', false);
|
|
await zp.setVirtual(groupLibraryID, 'recentlyRead', false);
|
|
Zotero.Items._lastReadCutoffs.clear();
|
|
});
|
|
});
|
|
|
|
describe("Select All (Cmd/Ctrl-A)", function () {
|
|
// Cmd/Ctrl-A is wired to _handleSelectAll() in the tree's keydown handler;
|
|
// the tests call it directly so they can await its (sometimes async) work.
|
|
|
|
it("should select all library roots when a library is selected", async function () {
|
|
let group = await createGroup();
|
|
let cv = zp.collectionsView;
|
|
await cv.selectByID("L" + Zotero.Libraries.userLibraryID);
|
|
await waitForItemsLoad(win);
|
|
|
|
await cv._handleSelectAll();
|
|
await zp.onCollectionSelected();
|
|
await zp.itemsView.waitForLoad();
|
|
|
|
let rows = zp.getCollectionTreeRows();
|
|
assert.isAbove(rows.length, 1, "More than one row should be selected");
|
|
assert.isTrue(rows.every(r => r.isLibrary(true)), "Every selected row should be a library root");
|
|
let libraryIDs = rows.map(r => r.ref.libraryID);
|
|
assert.include(libraryIDs, Zotero.Libraries.userLibraryID);
|
|
assert.include(libraryIDs, group.libraryID);
|
|
|
|
await group.eraseTx();
|
|
});
|
|
|
|
it("should select collections sharing a parent with any selected collection", async function () {
|
|
let cv = zp.collectionsView;
|
|
// Two parents with their own children. Selecting one child from each
|
|
// should expand to all children of both parents -- but not deeper
|
|
// (a subcollection) and not other libraries.
|
|
let parentA = await createDataObject('collection');
|
|
let a1 = await createDataObject('collection', { parentID: parentA.id });
|
|
let a2 = await createDataObject('collection', { parentID: parentA.id });
|
|
let parentB = await createDataObject('collection');
|
|
let b1 = await createDataObject('collection', { parentID: parentB.id });
|
|
let b2 = await createDataObject('collection', { parentID: parentB.id });
|
|
let sub = await createDataObject('collection', { parentID: a1.id });
|
|
let group = await createGroup();
|
|
let other = await createDataObject('collection', { libraryID: group.libraryID });
|
|
|
|
// Make the subcollection and the other library's collection visible rows
|
|
// so their exclusion is actually exercised, not just an absence of rows
|
|
await cv.expandToCollection(sub.id);
|
|
await cv.expandToCollection(b1.id);
|
|
await cv.expandLibrary(group.libraryID);
|
|
await cv.selectByID("C" + a1.id);
|
|
await waitForItemsLoad(win);
|
|
cv.selection.toggleSelect(cv.getRowIndexByID("C" + b1.id));
|
|
await zp.onCollectionSelected();
|
|
|
|
await cv._handleSelectAll();
|
|
await zp.onCollectionSelected();
|
|
await zp.itemsView.waitForLoad();
|
|
|
|
let rows = zp.getCollectionTreeRows();
|
|
assert.isTrue(rows.every(r => r.isCollection()), "Every selected row should be a collection");
|
|
assert.sameMembers(rows.map(r => r.ref), [a1, a2, b1, b2]);
|
|
|
|
await group.eraseTx();
|
|
});
|
|
|
|
it("should select Recently Read in all libraries", async function () {
|
|
let userLibraryID = Zotero.Libraries.userLibraryID;
|
|
let groupLibraryID = (await createGroup()).libraryID;
|
|
let cv = zp.collectionsView;
|
|
|
|
await zp.setVirtual(userLibraryID, 'recentlyRead', true, false);
|
|
await zp.setVirtual(groupLibraryID, 'recentlyRead', true, false);
|
|
// Collapse the group so its Recently Read row isn't in the tree -- Select
|
|
// All must expand it to reach Recently Read in every library. Do this
|
|
// before selecting, since collapseLibrary() selects the collapsed library.
|
|
cv.collapseLibrary(groupLibraryID);
|
|
assert.isFalse(cv.getRowIndexByID('Y' + groupLibraryID),
|
|
"Group Recently Read should be hidden before Select All");
|
|
await cv.selectByID('Y' + userLibraryID);
|
|
await waitForItemsLoad(win);
|
|
|
|
await cv._handleSelectAll();
|
|
await zp.onCollectionSelected();
|
|
await zp.itemsView.waitForLoad();
|
|
|
|
let rows = zp.getCollectionTreeRows();
|
|
assert.isTrue(rows.every(r => r.isRecentlyRead()), "Every selected row should be Recently Read");
|
|
let libraryIDs = rows.map(r => r.ref.libraryID);
|
|
assert.include(libraryIDs, userLibraryID);
|
|
assert.include(libraryIDs, groupLibraryID);
|
|
|
|
await zp.setVirtual(userLibraryID, 'recentlyRead', false);
|
|
await zp.setVirtual(groupLibraryID, 'recentlyRead', false);
|
|
});
|
|
|
|
it("should leave a saved-search selection untouched", async function () {
|
|
let cv = zp.collectionsView;
|
|
// Saved searches combine fine, so this is a valid manual multi-selection,
|
|
// but Select All has no useful expansion for them and must not change it
|
|
let s1 = await createDataObject('search');
|
|
let s2 = await createDataObject('search');
|
|
let s3 = await createDataObject('search');
|
|
await createDataObject('search');
|
|
await cv.selectByID("S" + s1.id);
|
|
await waitForItemsLoad(win);
|
|
cv.selection.toggleSelect(cv.getRowIndexByID("S" + s2.id));
|
|
cv.selection.toggleSelect(cv.getRowIndexByID("S" + s3.id));
|
|
await zp.onCollectionSelected();
|
|
assert.equal(cv.selection.count, 3);
|
|
|
|
await cv._handleSelectAll();
|
|
await zp.onCollectionSelected();
|
|
|
|
assert.equal(cv.selection.count, 3, "Select All should not add the fourth search");
|
|
let rows = zp.getCollectionTreeRows();
|
|
assert.sameMembers(rows.map(r => r.ref), [s1, s2, s3]);
|
|
});
|
|
});
|
|
|
|
describe("Items display", function () {
|
|
it("should show items from multiple selected collections", async function () {
|
|
let collection1 = await createDataObject('collection');
|
|
let collection2 = await createDataObject('collection');
|
|
let item1 = await createDataObject('item', { collections: [collection1.id] });
|
|
let item2 = await createDataObject('item', { collections: [collection2.id] });
|
|
|
|
await selectMultipleCollections([collection1, collection2]);
|
|
|
|
let itemsView = zp.itemsView;
|
|
assert.ok(itemsView.getRowIndexByID(item1.id) !== false, "Item from first collection should be shown");
|
|
assert.ok(itemsView.getRowIndexByID(item2.id) !== false, "Item from second collection should be shown");
|
|
});
|
|
|
|
it("should show items from subcollections with recursiveCollections", async function () {
|
|
Zotero.Prefs.set('recursiveCollections', true);
|
|
|
|
let collection1 = await createDataObject('collection');
|
|
let subCollection1 = await createDataObject('collection', { parentID: collection1.id });
|
|
let collection2 = await createDataObject('collection');
|
|
let subCollection2 = await createDataObject('collection', { parentID: collection2.id });
|
|
|
|
let item1 = await createDataObject('item', { collections: [subCollection1.id] });
|
|
let item2 = await createDataObject('item', { collections: [subCollection2.id] });
|
|
let item3 = await createDataObject('item', { collections: [collection1.id] });
|
|
|
|
await selectMultipleCollections([collection1, collection2]);
|
|
|
|
let itemsView = zp.itemsView;
|
|
assert.ok(itemsView.getRowIndexByID(item1.id) !== false,
|
|
"Item from subcollection of first collection should be shown");
|
|
assert.ok(itemsView.getRowIndexByID(item2.id) !== false,
|
|
"Item from subcollection of second collection should be shown");
|
|
assert.ok(itemsView.getRowIndexByID(item3.id) !== false,
|
|
"Item directly in first collection should be shown");
|
|
});
|
|
|
|
it("should not duplicate items that exist in both selected collections", async function () {
|
|
let collection1 = await createDataObject('collection');
|
|
let collection2 = await createDataObject('collection');
|
|
let item = await createDataObject('item', { collections: [collection1.id, collection2.id] });
|
|
|
|
await selectMultipleCollections([collection1, collection2]);
|
|
|
|
let itemsView = zp.itemsView;
|
|
// Count how many times the item appears. Compare the row's ref object
|
|
// rather than its id: a section header's ref is the library, whose id can
|
|
// collide with an item id in a fresh database.
|
|
let count = 0;
|
|
for (let i = 0; i < itemsView.rowCount; i++) {
|
|
if (itemsView.getRow(i).ref === item) {
|
|
count++;
|
|
}
|
|
}
|
|
assert.equal(count, 1, "Item in both collections should appear only once");
|
|
});
|
|
|
|
it("should group items by library for a cross-library selection", async function () {
|
|
let group = await createGroup();
|
|
let collection1 = await createDataObject('collection');
|
|
let collection2 = await createDataObject('collection', { libraryID: group.libraryID });
|
|
await createDataObject('item', { collections: [collection1.id] });
|
|
await createDataObject('item', { libraryID: group.libraryID, collections: [collection2.id] });
|
|
|
|
await zp.collectionsView.expandLibrary(group.libraryID);
|
|
await selectMultipleCollections([collection1, collection2]);
|
|
|
|
// The detailed grouping behavior (ordering, header heights, gating) is
|
|
// covered in collectionViewItemTreeTest; here just confirm that a
|
|
// cross-library selection produces a library-grouped view
|
|
assert.isNumber(zp.itemsView.getRowIndexByID("L" + Zotero.Libraries.userLibraryID),
|
|
"Cross-library selection should show a library header");
|
|
|
|
await selectLibrary(win);
|
|
await group.eraseTx();
|
|
});
|
|
});
|
|
|
|
describe("#newItem()", function () {
|
|
it("should add a new item to all selected collections", async function () {
|
|
let collection1 = await createDataObject('collection');
|
|
let collection2 = await createDataObject('collection');
|
|
|
|
await selectMultipleCollections([collection1, collection2]);
|
|
|
|
let item = await zp.newItem(Zotero.ItemTypes.getID('book'), {}, null, true);
|
|
|
|
assert.isTrue(item.inCollection(collection1.id),
|
|
"New item should be in first selected collection");
|
|
assert.isTrue(item.inCollection(collection2.id),
|
|
"New item should be in second selected collection");
|
|
});
|
|
});
|
|
|
|
describe("#duplicateSelectedItem()", function () {
|
|
it("should add duplicate to the subset of selected collections the original belongs to", async function () {
|
|
let collection1 = await createDataObject('collection');
|
|
let collection2 = await createDataObject('collection');
|
|
let collection3 = await createDataObject('collection');
|
|
// Item is in collections 1 and 3, not 2
|
|
let item = await createDataObject('item', { collections: [collection1.id, collection3.id] });
|
|
|
|
// Select collection1, which contains the item
|
|
await select(win, collection1);
|
|
await zp.itemsView.selectItem(item.id);
|
|
|
|
// Temporarily override getCollectionTreeRows to simulate multi-collection selection
|
|
let cv = zp.collectionsView;
|
|
let origGetRows = zp.getCollectionTreeRows;
|
|
let row1 = cv.getRowIndexByID("C" + collection1.id);
|
|
let row2 = cv.getRowIndexByID("C" + collection2.id);
|
|
let row3 = cv.getRowIndexByID("C" + collection3.id);
|
|
zp.getCollectionTreeRows = function () {
|
|
return [row1, row2, row3].map(index => cv.getRow(index));
|
|
};
|
|
|
|
try {
|
|
let duplicate = await zp.duplicateSelectedItem();
|
|
|
|
// Duplicate should be in collections 1 and 3 (selected + original is there)
|
|
// but not in collection 2 (selected but original isn't there)
|
|
assert.isTrue(duplicate.inCollection(collection1.id),
|
|
"Duplicate should be in collection 1 (selected and original exists)");
|
|
assert.isFalse(duplicate.inCollection(collection2.id),
|
|
"Duplicate should not be in collection 2 (selected but original doesn't exist)");
|
|
assert.isTrue(duplicate.inCollection(collection3.id),
|
|
"Duplicate should be in collection 3 (selected and original exists)");
|
|
}
|
|
finally {
|
|
zp.getCollectionTreeRows = origGetRows;
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("#deleteSelectedCollection()", function () {
|
|
it("should move all selected collections to trash", async function () {
|
|
let collection1 = await createDataObject('collection');
|
|
let collection2 = await createDataObject('collection');
|
|
|
|
await selectMultipleCollections([collection1, collection2]);
|
|
|
|
let promise = waitForDialog();
|
|
await zp.deleteSelectedCollection();
|
|
await promise;
|
|
|
|
assert.isTrue(collection1.deleted,
|
|
"First collection should be in trash");
|
|
assert.isTrue(collection2.deleted,
|
|
"Second collection should be in trash");
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("Quick search", function () {
|
|
var origWidth, origHeight;
|
|
|
|
before(function () {
|
|
origWidth = win.outerWidth;
|
|
origHeight = win.outerHeight;
|
|
});
|
|
|
|
after(function () {
|
|
win.resizeTo(origWidth, origHeight);
|
|
});
|
|
|
|
it("should keep the Advanced Search button within the items pane when the items pane is squeezed", async function () {
|
|
await selectLibrary(win);
|
|
// Narrow enough that the items pane (and its toolbar) is squeezed, but still wide
|
|
// enough to stay in standard (non-stacked) layout. With the collections pane and a
|
|
// wide item pane, this leaves the search field too narrow to reach its full width.
|
|
win.resizeTo(1000, 800);
|
|
await waitForCallback(() => win.innerWidth <= 1010, 10, 20);
|
|
|
|
let container = doc.getElementById('zotero-items-pane-container');
|
|
let search = doc.getElementById('zotero-tb-search');
|
|
let advButton = search.querySelector('#zotero-tb-search-advanced-button');
|
|
assert.ok(advButton, "Advanced Search button exists");
|
|
|
|
// Precondition: the field is squeezed below its max width, so the wrapper would
|
|
// overflow and push the trailing button out if it weren't allowed to shrink
|
|
assert.isBelow(search.getBoundingClientRect().width, 300,
|
|
"search field is squeezed below its full width");
|
|
// The button must stay within the items pane rather than overflowing under the item pane
|
|
assert.isAtMost(advButton.getBoundingClientRect().right, container.getBoundingClientRect().right,
|
|
"Advanced Search button's right edge is within the items pane");
|
|
});
|
|
});
|
|
})
|