mirror of
https://github.com/zotero/zotero.git
synced 2026-08-28 05:25:31 +00:00
The test relied on the automatic selection of a newly created item, which can lose the race with a view switch (e.g., from another library selected by a previous test), leaving the info pane empty.
3531 lines
130 KiB
JavaScript
3531 lines
130 KiB
JavaScript
describe("Item pane", function () {
|
||
var win, doc, ZoteroPane, Zotero_Tabs, ZoteroContextPane, itemsView;
|
||
|
||
async function waitForPreviewBoxRender(box, itemID) {
|
||
let res = await waitForCallback(
|
||
() => box._asyncRenderItemID && !box._asyncRendering
|
||
&& (!itemID || box._asyncRenderItemID == itemID),
|
||
100, 10);
|
||
if (res instanceof Error) {
|
||
throw res;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
async function waitForPreviewBoxReader(box, itemID) {
|
||
let preview = await getBoxPreview(box);
|
||
if (!preview) return false;
|
||
await waitForPreviewBoxRender(box);
|
||
let res = await waitForCallback(
|
||
() => preview._reader?.itemID == itemID
|
||
&& !preview._isProcessingTask && !preview._lastTask
|
||
, 100, 10);
|
||
if (res instanceof Error) {
|
||
throw res;
|
||
}
|
||
// Wait for reader initialization with timeout
|
||
let initTimeout = new Promise(resolve => setTimeout(() => resolve(false), 5000));
|
||
let initResult = await Promise.race([
|
||
preview._reader._initPromise,
|
||
initTimeout
|
||
]);
|
||
if (!initResult) {
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
async function isPreviewDisplayed(box) {
|
||
let preview = await getBoxPreview(box);
|
||
if (!preview) return false;
|
||
return !!(preview.hasPreview
|
||
&& win.getComputedStyle(preview).display !== "none");
|
||
}
|
||
|
||
async function getBoxPreview(box) {
|
||
try {
|
||
// Since we are lazy loading the preview, should wait for the preview to be initialized
|
||
await waitForCallback(
|
||
() => !!box._preview
|
||
, 10, 0.5);
|
||
}
|
||
catch (e) {
|
||
Zotero.logError(e);
|
||
// Return false if waitForCallback fails
|
||
return false;
|
||
}
|
||
return box._preview;
|
||
}
|
||
|
||
before(function* () {
|
||
win = yield loadZoteroPane();
|
||
doc = win.document;
|
||
ZoteroPane = win.ZoteroPane;
|
||
Zotero_Tabs = win.Zotero_Tabs;
|
||
ZoteroContextPane = win.ZoteroContextPane;
|
||
itemsView = win.ZoteroPane.itemsView;
|
||
});
|
||
|
||
after(function () {
|
||
Zotero_Tabs.select("zotero-pane");
|
||
Zotero_Tabs.closeAll();
|
||
win.close();
|
||
});
|
||
|
||
describe("Item pane header", function () {
|
||
let itemData = {
|
||
itemType: 'book',
|
||
title: 'Birds - A Primer of Ornithology (Teach Yourself Books)',
|
||
creators: [{
|
||
creatorType: 'author',
|
||
lastName: 'Hyde',
|
||
firstName: 'George E.'
|
||
}]
|
||
};
|
||
|
||
before(async function () {
|
||
await Zotero.Styles.init();
|
||
});
|
||
|
||
after(function () {
|
||
Zotero.Prefs.clear('itemPaneHeader');
|
||
Zotero.Prefs.clear('itemPaneHeader.bibEntry.style');
|
||
Zotero.Prefs.clear('itemPaneHeader.bibEntry.locale');
|
||
});
|
||
|
||
it("should be hidden when set to None mode", async function () {
|
||
Zotero.Prefs.set('itemPaneHeader', 'none');
|
||
await createDataObject('item', itemData);
|
||
assert.equal(doc.querySelector('item-pane-header').clientHeight, 0);
|
||
});
|
||
|
||
it("should show custom header elements when set to None mode", async function () {
|
||
Zotero.Prefs.set('itemPaneHeader', 'none');
|
||
|
||
// Use feed item toggle button as an example
|
||
let feed = await createFeed();
|
||
await selectLibrary(win, feed.libraryID);
|
||
await waitForItemsLoad(win);
|
||
|
||
var item = await createDataObject('feedItem', { libraryID: feed.libraryID });
|
||
await ZoteroPane.selectItem(item.id);
|
||
let feedButton = ZoteroPane.itemPane._itemDetails.querySelector('.feed-item-toggleRead-button');
|
||
assert.exists(feedButton);
|
||
|
||
await selectLibrary(win);
|
||
});
|
||
|
||
it("should show title when set to Title mode", async function () {
|
||
Zotero.Prefs.set('itemPaneHeader', 'title');
|
||
let item = await createDataObject('item', itemData);
|
||
|
||
assert.isFalse(doc.querySelector('item-pane-header .title').hidden);
|
||
assert.isTrue(doc.querySelector('item-pane-header .creator-year').hidden);
|
||
assert.isTrue(doc.querySelector('item-pane-header .bib-entry').hidden);
|
||
|
||
assert.equal(doc.querySelector('item-pane-header .title editable-text').value, item.getField('title'));
|
||
});
|
||
|
||
it("should show title/creator/year when set to Title/Creator/Year mode", async function () {
|
||
Zotero.Prefs.set('itemPaneHeader', 'titleCreatorYear');
|
||
let item = await createDataObject('item', itemData);
|
||
item.setField('date', '1962-05-01');
|
||
await item.saveTx();
|
||
|
||
assert.isTrue(doc.querySelector('item-pane-header .bib-entry').hidden);
|
||
assert.isFalse(doc.querySelector('item-pane-header .title').hidden);
|
||
assert.isFalse(doc.querySelector('item-pane-header .creator-year').hidden);
|
||
|
||
assert.equal(doc.querySelector('item-pane-header .title editable-text').value, item.getField('title'));
|
||
let creatorYearText = doc.querySelector('item-pane-header .creator-year').textContent;
|
||
assert.include(creatorYearText, 'Hyde');
|
||
assert.include(creatorYearText, '1962');
|
||
});
|
||
|
||
it("should show bib entry when set to Bibliography Entry mode", async function () {
|
||
Zotero.Prefs.set('itemPaneHeader', 'bibEntry');
|
||
Zotero.Prefs.set('itemPaneHeader.bibEntry.style', 'http://www.zotero.org/styles/apa');
|
||
await createDataObject('item', itemData);
|
||
|
||
assert.isFalse(doc.querySelector('item-pane-header .bib-entry').hidden);
|
||
assert.isTrue(doc.querySelector('item-pane-header .title').hidden);
|
||
assert.isTrue(doc.querySelector('item-pane-header .creator-year').hidden);
|
||
|
||
let bibEntry = doc.querySelector('item-pane-header .bib-entry').shadowRoot.firstElementChild.textContent;
|
||
assert.equal(bibEntry.trim(), 'Hyde, G. E. (n.d.). Birds—A Primer of Ornithology (Teach Yourself Books).');
|
||
});
|
||
|
||
it("should update bib entry on item change when set to Bibliography Entry mode", async function () {
|
||
Zotero.Prefs.set('itemPaneHeader', 'bibEntry');
|
||
Zotero.Prefs.set('itemPaneHeader.bibEntry.style', 'http://www.zotero.org/styles/apa');
|
||
let item = await createDataObject('item', itemData);
|
||
|
||
let bibEntryElem = doc.querySelector('item-pane-header .bib-entry').shadowRoot.firstElementChild;
|
||
|
||
assert.equal(bibEntryElem.textContent.trim(), 'Hyde, G. E. (n.d.). Birds—A Primer of Ornithology (Teach Yourself Books).');
|
||
|
||
item.setField('date', '1962-05-01');
|
||
await item.saveTx();
|
||
assert.equal(bibEntryElem.textContent.trim(), 'Hyde, G. E. (1962). Birds—A Primer of Ornithology (Teach Yourself Books).');
|
||
|
||
item.setCreators([
|
||
{
|
||
creatorType: 'author',
|
||
lastName: 'Smith',
|
||
firstName: 'John'
|
||
}
|
||
]);
|
||
await item.saveTx();
|
||
assert.equal(bibEntryElem.textContent.trim(), 'Smith, J. (1962). Birds—A Primer of Ornithology (Teach Yourself Books).');
|
||
|
||
item.setField('title', 'Birds');
|
||
await item.saveTx();
|
||
assert.equal(bibEntryElem.textContent.trim(), 'Smith, J. (1962). Birds.');
|
||
});
|
||
|
||
it("should update bib entry on style change when set to Bibliography Entry mode", async function () {
|
||
Zotero.Prefs.set('itemPaneHeader', 'bibEntry');
|
||
Zotero.Prefs.set('itemPaneHeader.bibEntry.style', 'http://www.zotero.org/styles/apa');
|
||
await createDataObject('item', itemData);
|
||
|
||
let bibEntryElem = doc.querySelector('item-pane-header .bib-entry').shadowRoot.firstElementChild;
|
||
|
||
assert.equal(bibEntryElem.textContent.trim(), 'Hyde, G. E. (n.d.). Birds—A Primer of Ornithology (Teach Yourself Books).');
|
||
|
||
Zotero.Prefs.set('itemPaneHeader.bibEntry.style', 'http://www.zotero.org/styles/chicago-author-date');
|
||
assert.equal(bibEntryElem.textContent.trim(), 'Hyde, George E. n.d. Birds - A Primer of Ornithology (Teach Yourself Books).');
|
||
});
|
||
|
||
it("should update bib entry on locale change when set to Bibliography Entry mode", async function () {
|
||
Zotero.Prefs.set('itemPaneHeader', 'bibEntry');
|
||
Zotero.Prefs.set('itemPaneHeader.bibEntry.style', 'http://www.zotero.org/styles/apa');
|
||
await createDataObject('item', itemData);
|
||
|
||
let bibEntryElem = doc.querySelector('item-pane-header .bib-entry').shadowRoot.firstElementChild;
|
||
|
||
assert.equal(bibEntryElem.textContent.trim(), 'Hyde, G. E. (n.d.). Birds—A Primer of Ornithology (Teach Yourself Books).');
|
||
|
||
Zotero.Prefs.set('itemPaneHeader.bibEntry.locale', 'de-DE');
|
||
assert.equal(bibEntryElem.textContent.trim(), 'Hyde, G. E. (o. J.). Birds—A Primer of Ornithology (Teach Yourself Books).');
|
||
});
|
||
|
||
it("should fall back to Title/Creator/Year when citation style is missing", async function () {
|
||
Zotero.Prefs.set('itemPaneHeader', 'bibEntry');
|
||
Zotero.Prefs.set('itemPaneHeader.bibEntry.style', 'http://www.zotero.org/styles/an-id-that-does-not-match-any-citation-style');
|
||
await createDataObject('item', itemData);
|
||
|
||
assert.isTrue(doc.querySelector('item-pane-header .bib-entry').hidden);
|
||
assert.isFalse(doc.querySelector('item-pane-header .title').hidden);
|
||
assert.isFalse(doc.querySelector('item-pane-header .creator-year').hidden);
|
||
});
|
||
|
||
it("should update custom header for items in the trash", async function () {
|
||
var item1 = await createDataObject('item', { deleted: true });
|
||
var item2 = await createDataObject('item', { deleted: true });
|
||
|
||
await selectTrash(win);
|
||
await ZoteroPane.selectItems([item1.id, item2.id]);
|
||
await waitForFrame();
|
||
|
||
let restoreButton = win.document.querySelector('#zotero-item-message .custom-head .item-restore-button');
|
||
assert.exists(restoreButton);
|
||
assert.exists(win.document.querySelector('#zotero-item-message .custom-head .item-delete-button'));
|
||
|
||
let refreshTrashPromise = waitForNotifierEvent('refresh', 'trash');
|
||
await restoreButton.click();
|
||
let ids = await waitForItemEvent('modify');
|
||
await refreshTrashPromise;
|
||
assert.equal(ids.length, 2);
|
||
|
||
assert.notExists(win.document.querySelector('#zotero-item-message .custom-head .item-restore-button'));
|
||
|
||
let promise = waitForItemEvent('delete');
|
||
await Zotero.DB.executeTransaction(async function () {
|
||
await item1.erase();
|
||
await item2.erase();
|
||
});
|
||
await promise;
|
||
|
||
await selectLibrary(win);
|
||
});
|
||
});
|
||
|
||
describe("Message pane", function () {
|
||
it("should update items-in-view count when an item is added with no selection", async function () {
|
||
var collection = await createDataObject('collection');
|
||
await ZoteroPane.collectionsView.selectCollection(collection.id);
|
||
await waitForItemsLoad(win);
|
||
|
||
var messageBox = doc.querySelector('#zotero-item-pane-message-box');
|
||
var emptyMessage = await doc.l10n.formatValue('item-pane-message-unselected', { count: 0 });
|
||
var oneItemMessage = await doc.l10n.formatValue('item-pane-message-unselected', { count: 1 });
|
||
for (let i = 0; i < 100 && !messageBox.textContent.includes(emptyMessage); i++) {
|
||
await Zotero.Promise.delay(10);
|
||
}
|
||
assert.include(messageBox.textContent, emptyMessage);
|
||
|
||
await createDataObject('item', { collections: [collection.id] }, { skipSelect: true });
|
||
|
||
for (let i = 0; i < 100 && !messageBox.textContent.includes(oneItemMessage); i++) {
|
||
await Zotero.Promise.delay(10);
|
||
}
|
||
assert.include(messageBox.textContent, oneItemMessage);
|
||
});
|
||
|
||
it("should update items-in-view count when a restored item is removed from the trash with no selection", async function () {
|
||
var group = await createGroup();
|
||
var item = await createDataObject('item', { libraryID: group.libraryID, deleted: true }, { skipSelect: true });
|
||
await selectTrash(win, group.libraryID);
|
||
|
||
var messageBox = doc.querySelector('#zotero-item-pane-message-box');
|
||
var emptyMessage = await doc.l10n.formatValue('item-pane-message-unselected', { count: 0 });
|
||
var oneItemMessage = await doc.l10n.formatValue('item-pane-message-unselected', { count: 1 });
|
||
for (let i = 0; i < 100 && !messageBox.textContent.includes(oneItemMessage); i++) {
|
||
await Zotero.Promise.delay(10);
|
||
}
|
||
assert.include(messageBox.textContent, oneItemMessage);
|
||
|
||
// Restore the item without notifications, so that the row remains until a
|
||
// 'refresh' notification arrives (e.g., from retractions or full-text
|
||
// indexing) and the tree notices that the item is no longer deleted
|
||
item.deleted = false;
|
||
await item.saveTx({ skipNotifier: true });
|
||
assert.include(messageBox.textContent, oneItemMessage);
|
||
|
||
await Zotero.Notifier.trigger('refresh', 'item', [item.id]);
|
||
|
||
for (let i = 0; i < 100 && !messageBox.textContent.includes(emptyMessage); i++) {
|
||
await Zotero.Promise.delay(10);
|
||
}
|
||
assert.include(messageBox.textContent, emptyMessage);
|
||
});
|
||
});
|
||
|
||
describe("Info pane", function () {
|
||
before(async () => {
|
||
await activateZoteroPane();
|
||
});
|
||
it("should place Title after Item Type and before creators", async function () {
|
||
var item = await createDataObject('item');
|
||
var itemPane = win.ZoteroPane.itemPane;
|
||
var fields = [...itemPane.querySelectorAll('.meta-label')]
|
||
.map(x => x.getAttribute('fieldname'));
|
||
assert.equal(fields[0], 'itemType');
|
||
assert.equal(fields[1], 'title');
|
||
assert.isTrue(fields[2].startsWith('creator'));
|
||
});
|
||
|
||
it("should refresh on item update", async function () {
|
||
var item = new Zotero.Item('book');
|
||
var id = await item.saveTx();
|
||
await ZoteroPane.selectItem(id);
|
||
|
||
var itemBox = doc.getElementById('zotero-editpane-info-box');
|
||
var label = itemBox.querySelectorAll('[fieldname="series"]')[1];
|
||
assert.equal(label.value, '');
|
||
|
||
let promise = waitForItemEvent('modify');
|
||
item.setField('series', 'Test');
|
||
await item.saveTx();
|
||
await promise;
|
||
|
||
label = itemBox.querySelectorAll('[fieldname="series"]')[1];
|
||
assert.equal(label.value, 'Test');
|
||
|
||
await Zotero.Items.erase(id);
|
||
});
|
||
|
||
|
||
it("should swap creator names", async function () {
|
||
var item = new Zotero.Item('book');
|
||
item.setCreators([
|
||
{
|
||
firstName: "First",
|
||
lastName: "Last",
|
||
creatorType: "author"
|
||
}
|
||
]);
|
||
await item.saveTx();
|
||
|
||
var itemBox = doc.getElementById('zotero-editpane-info-box');
|
||
var lastName = itemBox.querySelector('#itembox-field-value-creator-0-lastName');
|
||
var parent = lastName.closest(".creator-type-value");
|
||
assert.property(parent, 'oncontextmenu');
|
||
assert.isFunction(parent.oncontextmenu);
|
||
|
||
var menupopup = itemBox.querySelector('#zotero-creator-transform-menu');
|
||
// Fake a right-click
|
||
itemBox._popupNode = parent;
|
||
menupopup.openPopup(
|
||
parent, "after_start", 0, 0, true, false, new MouseEvent('click', { button: 2 })
|
||
);
|
||
var menuitem = menupopup.getElementsByTagName('menuitem')[0];
|
||
menuitem.click();
|
||
await waitForItemEvent('modify');
|
||
|
||
var creator = item.getCreators()[0];
|
||
assert.propertyVal(creator, 'firstName', 'Last');
|
||
assert.propertyVal(creator, 'lastName', 'First');
|
||
});
|
||
|
||
|
||
it("shouldn't show Swap Names option for single-field mode", async function () {
|
||
var item = new Zotero.Item('book');
|
||
item.setCreators([
|
||
{
|
||
name: "Name",
|
||
creatorType: "author"
|
||
}
|
||
]);
|
||
await item.saveTx();
|
||
|
||
var itemBox = doc.getElementById('zotero-editpane-info-box');
|
||
var label = itemBox.querySelector('#itembox-field-value-creator-0-lastName');
|
||
var firstlast = label.closest('.creator-type-value');
|
||
var menupopup = itemBox.querySelector('#zotero-creator-transform-menu');
|
||
// Fake a right-click
|
||
itemBox._popupNode = firstlast;
|
||
menupopup.openPopup(
|
||
firstlast, "after_start", 0, 0, true, false, new MouseEvent('click', { button: 2 })
|
||
);
|
||
|
||
var menuitem = doc.getElementById('creator-transform-swap-names');
|
||
assert.isTrue(menuitem.hidden);
|
||
});
|
||
|
||
it("should reorder creators", async function () {
|
||
var item = new Zotero.Item('book');
|
||
item.setCreators([
|
||
{
|
||
lastName: "One",
|
||
creatorType: "author"
|
||
},
|
||
{
|
||
lastName: "Two",
|
||
creatorType: "author"
|
||
},
|
||
{
|
||
lastName: "Three",
|
||
creatorType: "author"
|
||
}
|
||
]);
|
||
await item.saveTx();
|
||
|
||
var itemBox = doc.getElementById('zotero-editpane-info-box');
|
||
// Move One to the last spot
|
||
itemBox.moveCreator(0, null, 3);
|
||
await waitForItemEvent('modify');
|
||
let thirdLastName = itemBox.querySelector("[fieldname='creator-2-lastName']").value;
|
||
assert.equal(thirdLastName, "One");
|
||
|
||
// Move One to the second spot
|
||
itemBox.moveCreator(2, null, 1);
|
||
await waitForItemEvent('modify');
|
||
let secondLastname = itemBox.querySelector("[fieldname='creator-1-lastName']").value;
|
||
assert.equal(secondLastname, "One");
|
||
|
||
// Move Two down
|
||
itemBox.moveCreator(0, 'down');
|
||
await waitForItemEvent('modify');
|
||
secondLastname = itemBox.querySelector("[fieldname='creator-1-lastName']").value;
|
||
let firstLastName = itemBox.querySelector("[fieldname='creator-0-lastName']").value;
|
||
assert.equal(secondLastname, "Two");
|
||
assert.equal(firstLastName, "One");
|
||
|
||
// Move Three up
|
||
itemBox.moveCreator(2, 'up');
|
||
await waitForItemEvent('modify');
|
||
secondLastname = itemBox.querySelector("[fieldname='creator-1-lastName']").value;
|
||
thirdLastName = itemBox.querySelector("[fieldname='creator-2-lastName']").value;
|
||
assert.equal(secondLastname, "Three");
|
||
assert.equal(thirdLastName, "Two");
|
||
});
|
||
|
||
|
||
// Note: This issue applies to all context menus in the item box (text transform, name swap),
|
||
// though the others aren't tested. This might go away with the XUL->HTML transition.
|
||
it.skip("should save open field after changing creator type", function* () {
|
||
var item = new Zotero.Item('book');
|
||
item.setCreators([
|
||
{
|
||
firstName: "First",
|
||
lastName: "Last",
|
||
creatorType: "author"
|
||
}
|
||
]);
|
||
var id = yield item.saveTx();
|
||
|
||
var itemBox = doc.getElementById('zotero-editpane-info-box');
|
||
var label = itemBox.querySelector('[fieldname="place"]');
|
||
label.click();
|
||
var textbox = itemBox.querySelector('[fieldname="place"]');
|
||
textbox.value = "Place";
|
||
|
||
var menuLabel = itemBox.querySelector('[fieldname="creator-0-typeID"]');
|
||
menuLabel.click();
|
||
var menupopup = itemBox._creatorTypeMenu;
|
||
var menuItems = menupopup.getElementsByTagName('menuitem');
|
||
menuItems[1].click();
|
||
yield waitForItemEvent('modify');
|
||
|
||
assert.equal(item.getField('place'), 'Place');
|
||
assert.equal(Zotero.CreatorTypes.getName(item.getCreators()[0].creatorTypeID), 'contributor');
|
||
|
||
// Wait for no-op saveTx()
|
||
yield Zotero.Promise.delay(1);
|
||
});
|
||
|
||
it("should accept 'now' for Accessed", async function () {
|
||
var item = await createDataObject('item');
|
||
|
||
var itemBox = doc.getElementById('zotero-editpane-info-box');
|
||
var textbox = itemBox.querySelector('[fieldname="accessDate"]');
|
||
textbox.value = 'now';
|
||
// Blur events don't necessarily trigger if window doesn't have focus
|
||
itemBox.hideEditor(textbox);
|
||
|
||
await waitForItemEvent('modify');
|
||
|
||
assert.approximately(
|
||
Zotero.Date.sqlToDate(item.getField('accessDate'), true).getTime(),
|
||
Date.now(),
|
||
5000
|
||
);
|
||
});
|
||
|
||
it("should persist fieldMode after hiding a creator name editor", async function () {
|
||
let item = new Zotero.Item('book');
|
||
item.setCreators([
|
||
{
|
||
name: "First Last",
|
||
creatorType: "author",
|
||
fieldMode: 1
|
||
}
|
||
]);
|
||
await item.saveTx();
|
||
|
||
let itemBox = doc.getElementById('zotero-editpane-info-box');
|
||
|
||
itemBox.querySelector('[fieldname="creator-0-lastName"]').click();
|
||
itemBox.hideEditor(itemBox.querySelector('input[fieldname="creator-0-lastName"]'));
|
||
|
||
assert.equal(
|
||
itemBox.querySelector('[fieldname="creator-0-lastName"]').getAttribute('fieldMode'),
|
||
'1'
|
||
);
|
||
});
|
||
|
||
it("should add a new empty creator row on shift-Enter", async function () {
|
||
var item = await createDataObject('item');
|
||
item.setCreators([
|
||
{
|
||
lastName: "One",
|
||
creatorType: "author"
|
||
},
|
||
{
|
||
lastName: "Two",
|
||
creatorType: "author"
|
||
},
|
||
{
|
||
lastName: "Three",
|
||
creatorType: "author"
|
||
}
|
||
]);
|
||
let promise = waitForItemEvent('modify');
|
||
item.saveTx();
|
||
await promise;
|
||
var itemBox = doc.getElementById('zotero-editpane-info-box');
|
||
let creatorLastName = itemBox.querySelector(".creator-type-value editable-text");
|
||
creatorLastName.focus();
|
||
// Dispatch shift-Enter event
|
||
var shiftEnter = new KeyboardEvent('keydown', {
|
||
key: "Enter",
|
||
shiftKey: true,
|
||
bubbles: true
|
||
});
|
||
creatorLastName.ref.dispatchEvent(shiftEnter);
|
||
// Wait a moment for new row to be added
|
||
await Zotero.Promise.delay();
|
||
// Make sure an unsaved empty creator row is focused
|
||
assert.exists(doc.activeElement.closest("[unsaved=true]"));
|
||
// Make sure it is added after the existing row
|
||
let { position } = itemBox.getCreatorFields(doc.activeElement.closest(".meta-row"));
|
||
assert.equal(position, 1);
|
||
});
|
||
|
||
it("should do nothing on shift-Enter in an empty unsaved row", async function () {
|
||
var item = await createDataObject('item');
|
||
item.setCreators([
|
||
{
|
||
lastName: "One",
|
||
creatorType: "author"
|
||
}
|
||
]);
|
||
let promise = waitForItemEvent('modify');
|
||
item.saveTx();
|
||
await promise;
|
||
var itemBox = doc.getElementById('zotero-editpane-info-box');
|
||
let creatorLastName = itemBox.querySelector(".creator-type-value editable-text");
|
||
creatorLastName.focus();
|
||
// Dispatch shift-Enter event
|
||
var shiftEnter = new KeyboardEvent('keydown', {
|
||
key: "Enter",
|
||
shiftKey: true,
|
||
bubbles: true
|
||
});
|
||
creatorLastName.ref.dispatchEvent(shiftEnter);
|
||
// Wait a moment for new row to be added
|
||
await Zotero.Promise.delay();
|
||
// Make sure an unsaved empty creator row is focused
|
||
assert.exists(doc.activeElement.closest("[unsaved=true]"));
|
||
// Mark current creator input
|
||
doc.activeElement.id = "test_creator_row";
|
||
// Field with just space should be treated as empty
|
||
doc.activeElement.value = " ";
|
||
// Dispatch shift-Enter event again
|
||
doc.activeElement.dispatchEvent(shiftEnter);
|
||
// Make sure we're still on the same field
|
||
await Zotero.Promise.delay();
|
||
assert.equal(doc.activeElement.id, "test_creator_row");
|
||
});
|
||
|
||
it("should display all creators on shift-Enter on last visible creator", async function () {
|
||
var item = await createDataObject('item');
|
||
const creatorsCount = 10;
|
||
let creatorsArr = [];
|
||
let i = 0;
|
||
// Add many creators so that some of them are not rendered
|
||
while (i < creatorsCount) {
|
||
i += 1;
|
||
creatorsArr.push({ lastName: "Creator " + i, creatorType: "author" });
|
||
}
|
||
item.setCreators(creatorsArr);
|
||
item.saveTx();
|
||
await waitForItemEvent('modify');
|
||
var itemBox = doc.getElementById('zotero-editpane-info-box');
|
||
let moreCreatorsLabel = itemBox.querySelector("#more-creators-label");
|
||
let lastVisibleCreator = moreCreatorsLabel.closest(".meta-row").previousElementSibling;
|
||
let lastVisibleCreatorsPosition = itemBox.getCreatorFields(lastVisibleCreator).position;
|
||
// Dispatch shift-Enter on the last visible creator row
|
||
let creatorLastName = lastVisibleCreator.querySelector("editable-text");
|
||
creatorLastName.focus();
|
||
var shiftEnter = new KeyboardEvent('keydown', {
|
||
key: "Enter",
|
||
shiftKey: true,
|
||
bubbles: true
|
||
});
|
||
creatorLastName.ref.dispatchEvent(shiftEnter);
|
||
await Zotero.Promise.delay();
|
||
// Make sure a new creator row is focused
|
||
assert.exists(doc.activeElement.closest("[unsaved=true]"));
|
||
// Make sure it is located after the last focused row
|
||
let { position } = itemBox.getCreatorFields(doc.activeElement.closest(".meta-row"));
|
||
assert.equal(position, lastVisibleCreatorsPosition + 1);
|
||
// Make sure all other creator rows were rendered
|
||
let creators = [...itemBox.querySelectorAll(".creator-type-value")];
|
||
assert.equal(creators.length, creatorsCount + 1);
|
||
});
|
||
|
||
it("should not delete invisible creators on Escape on unsaved creator", async function () {
|
||
var item = await createDataObject('item');
|
||
const creatorsCount = 10;
|
||
let creatorsArr = [];
|
||
let i = 0;
|
||
// Add many creators so that some of them are not rendered
|
||
while (i < creatorsCount) {
|
||
i += 1;
|
||
creatorsArr.push({ lastName: "Creator " + i, creatorType: "author" });
|
||
}
|
||
item.setCreators(creatorsArr);
|
||
item.saveTx();
|
||
await waitForItemEvent('modify');
|
||
var itemBox = doc.getElementById('zotero-editpane-info-box');
|
||
// Add a new empty creator row
|
||
itemBox.querySelector(".zotero-clicky-plus").click();
|
||
await Zotero.Promise.delay();
|
||
assert.exists(doc.activeElement.closest("[unsaved=true]"));
|
||
// Press Escape
|
||
var escape = new KeyboardEvent('keydown', {
|
||
key: "Escape",
|
||
bubbles: true
|
||
});
|
||
doc.activeElement.dispatchEvent(escape);
|
||
await Zotero.Promise.delay();
|
||
// Make sure the creator count has not changed and "More creators" label is still there
|
||
let creators = [...itemBox.querySelectorAll(".creator-type-value")];
|
||
assert.exists(itemBox.querySelector("#more-creators-label"));
|
||
assert.equal(creators.length, itemBox._initialVisibleCreators);
|
||
assert.equal(item.numCreators(), creatorsCount);
|
||
});
|
||
|
||
it("should switch creator type and update pref", async function () {
|
||
let item = await createDataObject('item');
|
||
item.setCreators([
|
||
{
|
||
name: "First Last",
|
||
creatorType: "author",
|
||
fieldMode: 1
|
||
}
|
||
]);
|
||
// Begin with 'single' creator mode
|
||
Zotero.Prefs.set('lastCreatorFieldMode', 1);
|
||
let modifyPromise = waitForItemEvent('modify');
|
||
item.saveTx();
|
||
await modifyPromise;
|
||
var itemBox = doc.getElementById('zotero-editpane-info-box');
|
||
// Click on the button to switch type to dual
|
||
let switchTypeBtn = itemBox.querySelector(".zotero-clicky-switch-type");
|
||
assert.equal(switchTypeBtn.getAttribute("type"), "single");
|
||
modifyPromise = waitForItemEvent('modify');
|
||
switchTypeBtn.click();
|
||
await modifyPromise;
|
||
// Make sure the button was updated and the names are displayed in two separate fields
|
||
switchTypeBtn = itemBox.querySelector(".zotero-clicky-switch-type");
|
||
assert.equal(switchTypeBtn.getAttribute("type"), "dual");
|
||
let [lastName, firstName] = [...itemBox.querySelectorAll(".creator-name-box editable-text")];
|
||
assert.equal(lastName.value, "Last");
|
||
assert.equal(firstName.value, "First");
|
||
|
||
assert.equal(Zotero.Prefs.get('lastCreatorFieldMode'), '0');
|
||
|
||
// Make sure if a new row is added, it is of type dual
|
||
itemBox.querySelector(".zotero-clicky-plus").click();
|
||
await Zotero.Promise.delay();
|
||
let newCreatorRow = doc.activeElement.closest(".meta-row");
|
||
let fieldMode = newCreatorRow.querySelector("editable-text").getAttribute("fieldMode");
|
||
assert.equal(fieldMode, "0");
|
||
});
|
||
|
||
it("should save updated title when switching between items", async function () {
|
||
let itemOne = new Zotero.Item('book');
|
||
let itemTwo = new Zotero.Item('book');
|
||
itemOne.setField('title', 'Title_one');
|
||
await itemOne.saveTx();
|
||
await itemTwo.saveTx();
|
||
await ZoteroPane.selectItem(itemOne.id);
|
||
|
||
let itemDetails = ZoteroPane.itemPane._itemDetails;
|
||
let infoBox = itemDetails.getPane("info");
|
||
|
||
let titleField = infoBox.querySelector("#itembox-field-value-title");
|
||
titleField.focus();
|
||
titleField.value = "Updated title";
|
||
await ZoteroPane.selectItem(itemTwo.id);
|
||
await waitForNotifierEvent('modify', 'item');
|
||
assert.equal(itemOne.getDisplayTitle(), "Updated title");
|
||
});
|
||
|
||
it("should retain unsaved value between refreshes", async function () {
|
||
let itemOne = new Zotero.Item('book');
|
||
await ZoteroPane.selectItem(itemOne.id);
|
||
|
||
let itemDetails = ZoteroPane.itemPane._itemDetails;
|
||
let infoBox = itemDetails.getPane("info");
|
||
|
||
// Type something into the series field without saving it
|
||
let seriesField = infoBox.querySelector("#itembox-field-value-series");
|
||
seriesField.focus();
|
||
seriesField.value = "Series name";
|
||
|
||
// Trigger a refresh
|
||
infoBox._renderInternal();
|
||
await waitForFrame();
|
||
|
||
// Ensure the field is still focused AND has the yet-unsaved text
|
||
assert.equal(doc.activeElement.parentNode.id, "itembox-field-value-series");
|
||
assert.equal(doc.activeElement.value, "Series name");
|
||
});
|
||
|
||
it("should not loose focus on Tab from invalid DOI", async function () {
|
||
var item = new Zotero.Item('journalArticle');
|
||
item.setField('DOI', '10.3390/fluids10110300');
|
||
await item.saveTx();
|
||
await ZoteroPane.selectItem(item.id);
|
||
|
||
var itemBox = doc.getElementById('zotero-editpane-info-box');
|
||
|
||
// Focus the valid DOI field and tab from it
|
||
let doiField = itemBox.querySelector('#itembox-field-value-DOI');
|
||
doiField.focus();
|
||
assert.isTrue(doiField.contains(doc.activeElement));
|
||
|
||
Services.focus.moveFocus(win, doc.activeElement, Services.focus.MOVEFOCUS_FORWARD, 0);
|
||
await waitForItemEvent('modify');
|
||
|
||
// The open-link button for DOI should be focused
|
||
assert.equal(doc.activeElement.id, 'itembox-field-DOI-link');
|
||
|
||
// Now set DOI to an invalid value and tab from it again
|
||
doiField.focus();
|
||
doiField.value = '123';
|
||
|
||
Services.focus.moveFocus(win, doc.activeElement, Services.focus.MOVEFOCUS_FORWARD, 0);
|
||
await waitForItemEvent('modify')
|
||
|
||
// Focus should land on the citation key field
|
||
let citationKeyField = itemBox.querySelector('#itembox-field-value-citationKey');
|
||
assert.isTrue(citationKeyField.contains(doc.activeElement));
|
||
});
|
||
});
|
||
|
||
describe("Libraries and collections pane", function () {
|
||
var item, collectionParent, collectionChild, section;
|
||
|
||
// Fresh setup of an item belonging to 2 collections - parent and child - for each test
|
||
beforeEach(async function () {
|
||
collectionParent = await createDataObject('collection');
|
||
collectionChild = await createDataObject('collection', { parentID: collectionParent.id });
|
||
item = await createDataObject('item', { collections: [collectionParent.id, collectionChild.id] });
|
||
await ZoteroPane.selectItem(item.id);
|
||
section = ZoteroPane.itemPane._itemDetails.getPane("libraries-collections");
|
||
});
|
||
|
||
it("should update collection's name after rename", async function () {
|
||
collectionChild.name = "Updated collection name";
|
||
collectionChild.saveTx();
|
||
|
||
await waitForNotifierEvent('modify', 'collection');
|
||
|
||
let collectionRow = section.querySelector(`.row[data-id="C${collectionChild.id}"]`);
|
||
assert.equal(collectionRow.innerText, collectionChild.name);
|
||
});
|
||
|
||
it("should remove collection that has been trashed", async function () {
|
||
collectionChild.deleted = true;
|
||
collectionChild.saveTx();
|
||
|
||
await waitForNotifierEvent('trash', 'collection');
|
||
|
||
let rowIDs = [...section.querySelectorAll(".row")].map(node => node.dataset.id);
|
||
assert.deepEqual(rowIDs, [`L${item.libraryID}`, `C${collectionParent.id}`]);
|
||
});
|
||
|
||
it("should bring back collection restored from trash", async function () {
|
||
collectionChild.deleted = true;
|
||
collectionChild.saveTx();
|
||
|
||
await waitForNotifierEvent('trash', 'collection');
|
||
|
||
// Make sure the collection is actually gone
|
||
let rowIDs = [...section.querySelectorAll(".row")].map(node => node.dataset.id);
|
||
assert.deepEqual(rowIDs, [`L${item.libraryID}`, `C${collectionParent.id}`]);
|
||
|
||
// Restore the collection from trash
|
||
collectionChild.deleted = false;
|
||
collectionChild.saveTx();
|
||
|
||
await waitForNotifierEvent('modify', 'collection');
|
||
|
||
// The collection row should appear again
|
||
rowIDs = [...section.querySelectorAll(".row")].map(node => node.dataset.id);
|
||
assert.deepEqual(rowIDs, [`L${item.libraryID}`, `C${collectionParent.id}`, `C${collectionChild.id}`]);
|
||
});
|
||
|
||
it("should mark every selected collection as current for a multiple-collection selection", async function () {
|
||
// Select both collections the item belongs to. Select the (sub)collection
|
||
// first so its row is revealed, then toggle the parent into the selection.
|
||
let cv = ZoteroPane.collectionsView;
|
||
await cv.selectByID("C" + collectionChild.id);
|
||
await waitForItemsLoad(win);
|
||
cv.selection.toggleSelect(cv.getRowIndexByID("C" + collectionParent.id));
|
||
await ZoteroPane.onCollectionSelected();
|
||
await ZoteroPane.itemsView.waitForLoad();
|
||
// Select within the current (multi-collection) view rather than
|
||
// ZoteroPane.selectItem(), which would navigate and drop the selection
|
||
await ZoteroPane.itemsView.selectItem(item.id);
|
||
// itemSelected() pushes the current selection into the item pane; force a
|
||
// render so the section reflects the multi-collection selection (item-pane
|
||
// render is skipped when the selected item itself hasn't changed)
|
||
await ZoteroPane.itemSelected();
|
||
await ZoteroPane.itemPane.render();
|
||
|
||
section = ZoteroPane.itemPane._itemDetails.getPane("libraries-collections");
|
||
let parentBox = section.querySelector(`.row[data-id="C${collectionParent.id}"] .box`);
|
||
let childBox = section.querySelector(`.row[data-id="C${collectionChild.id}"] .box`);
|
||
let libraryBox = section.querySelector(`.row[data-id="L${item.libraryID}"] .box`);
|
||
|
||
// Both selected collections are bolded (.current); the unselected library row is not
|
||
assert.isTrue(parentBox.classList.contains('current'));
|
||
assert.isTrue(childBox.classList.contains('current'));
|
||
assert.isFalse(libraryBox.classList.contains('current'));
|
||
|
||
await selectLibrary(win);
|
||
});
|
||
});
|
||
|
||
describe("Attachments pane", function () {
|
||
let paneID = "attachments";
|
||
|
||
beforeEach(async function () {
|
||
Zotero.Prefs.set("panes.attachments.open", true);
|
||
Zotero.Prefs.set("showAttachmentPreview", true);
|
||
Zotero_Tabs.select("zotero-pane");
|
||
win.resizeTo(1000, 800);
|
||
// Wait for the resize to take effect, since some tests shrink the window without
|
||
// waiting for the restore at the end
|
||
await waitForCallback(() => win.outerWidth == 1000 && win.outerHeight == 800, 100, 3);
|
||
});
|
||
|
||
afterEach(function () {
|
||
// Ensure all previews are properly discarded and cleaned up
|
||
let itemDetails = ZoteroPane.itemPane._itemDetails;
|
||
let attachmentsBox = itemDetails.getPane(paneID);
|
||
|
||
// Force cleanup of any pending operations and queued tasks
|
||
if (attachmentsBox._preview) {
|
||
attachmentsBox._preview._clearPendingTasks();
|
||
attachmentsBox._preview.discard?.();
|
||
}
|
||
|
||
Zotero_Tabs.select("zotero-pane");
|
||
Zotero_Tabs.closeAll();
|
||
});
|
||
|
||
after(function () {
|
||
win.resizeTo(1000, 800);
|
||
});
|
||
|
||
it("should show attachments pane in library for regular item", async function () {
|
||
// Regular item: show
|
||
let attachmentsBox = ZoteroPane.itemPane._itemDetails.getPane(paneID);
|
||
let item = new Zotero.Item('book');
|
||
await item.saveTx();
|
||
await ZoteroPane.selectItem(item.id);
|
||
assert.isFalse(attachmentsBox.hidden);
|
||
|
||
// Child attachment: hide
|
||
let file = getTestDataDirectory();
|
||
file.append('test.pdf');
|
||
let attachment = await Zotero.Attachments.importFromFile({
|
||
file,
|
||
parentItemID: item.id
|
||
});
|
||
await ZoteroPane.selectItem(attachment.id);
|
||
assert.isTrue(attachmentsBox.hidden);
|
||
|
||
// Standalone attachment: hide
|
||
let attachment1 = await importFileAttachment('test.pdf');
|
||
await ZoteroPane.selectItem(attachment1.id);
|
||
assert.isTrue(attachmentsBox.hidden);
|
||
});
|
||
|
||
it("should not show attachments pane preview in reader best-matched attachment item", async function () {
|
||
let item = new Zotero.Item('book');
|
||
let file = getTestDataDirectory();
|
||
file.append('test.pdf');
|
||
await item.saveTx();
|
||
let attachment = await Zotero.Attachments.importFromFile({
|
||
file,
|
||
parentItemID: item.id
|
||
});
|
||
await ZoteroPane.viewItems([attachment]);
|
||
let tabID = Zotero_Tabs.selectedID;
|
||
ZoteroContextPane.splitter.setAttribute("state", "open");
|
||
let itemDetails = ZoteroContextPane.context._getItemContext(tabID);
|
||
let attachmentsBox = itemDetails.getPane(paneID);
|
||
assert.isFalse(attachmentsBox.hidden);
|
||
|
||
await waitForScrollToPane(itemDetails, paneID);
|
||
|
||
assert.isFalse(await isPreviewDisplayed(attachmentsBox));
|
||
});
|
||
|
||
it("should not show attachments pane in reader standalone attachment item", async function () {
|
||
let attachment = await importFileAttachment('test.pdf');
|
||
await ZoteroPane.viewItems([attachment]);
|
||
let tabID = Zotero_Tabs.selectedID;
|
||
let itemDetails = ZoteroContextPane.context._getItemContext(tabID);
|
||
let attachmentsBox = itemDetails.getPane(paneID);
|
||
assert.isTrue(attachmentsBox.hidden);
|
||
});
|
||
|
||
it("should show attachments pane preview in reader non-best-matched attachment item", async function () {
|
||
let item = new Zotero.Item('book');
|
||
let file = getTestDataDirectory();
|
||
file.append('test.pdf');
|
||
await item.saveTx();
|
||
await Zotero.Attachments.importFromFile({
|
||
file,
|
||
parentItemID: item.id
|
||
});
|
||
await Zotero.Attachments.importFromFile({
|
||
file,
|
||
parentItemID: item.id
|
||
});
|
||
|
||
let bestAttachments = await item.getBestAttachments();
|
||
await ZoteroPane.viewItems([bestAttachments[1]]);
|
||
// Ensure context pane is open
|
||
ZoteroContextPane.splitter.setAttribute("state", "open");
|
||
await waitForFrame();
|
||
let tabID = Zotero_Tabs.selectedID;
|
||
let itemDetails = ZoteroContextPane.context._getItemContext(tabID);
|
||
let attachmentsBox = itemDetails.getPane(paneID);
|
||
assert.isFalse(attachmentsBox.hidden);
|
||
|
||
await waitForScrollToPane(itemDetails, paneID);
|
||
await waitForPreviewBoxRender(attachmentsBox);
|
||
assert.isTrue(await isPreviewDisplayed(attachmentsBox));
|
||
});
|
||
|
||
it("should not render attachments pane preview when show preview is disabled", async function () {
|
||
Zotero.Prefs.set("showAttachmentPreview", false);
|
||
|
||
let itemDetails = ZoteroPane.itemPane._itemDetails;
|
||
let attachmentsBox = itemDetails.getPane(paneID);
|
||
let item = new Zotero.Item('book');
|
||
await item.saveTx();
|
||
await ZoteroPane.selectItem(item.id);
|
||
assert.isFalse(attachmentsBox.hidden);
|
||
|
||
await waitForScrollToPane(itemDetails, paneID);
|
||
|
||
assert.isFalse(await isPreviewDisplayed(attachmentsBox));
|
||
});
|
||
|
||
it("should only render after attachments pane becomes visible", async function () {
|
||
// Resize to very small height to ensure the attachment box is not in view
|
||
let height = doc.documentElement.clientHeight;
|
||
win.resizeTo(null, 100);
|
||
|
||
let itemDetails = ZoteroPane.itemPane._itemDetails;
|
||
let attachmentsBox = itemDetails.getPane(paneID);
|
||
let preview = attachmentsBox.previewElem;
|
||
// Force discard previous preview
|
||
await preview.discard(true);
|
||
|
||
let item = new Zotero.Item('book');
|
||
await item.saveTx();
|
||
let file = getTestDataDirectory();
|
||
file.append('test.pdf');
|
||
await Zotero.Attachments.importFromFile({
|
||
file,
|
||
parentItemID: item.id
|
||
});
|
||
|
||
await ZoteroPane.selectItem(item.id);
|
||
assert.isFalse(itemDetails.isPaneVisible(paneID));
|
||
// Do not use _isAlreadyRendered, since that changes the render flag state
|
||
assert.equal(attachmentsBox._syncRenderItemID, item.id);
|
||
assert.notEqual(attachmentsBox._asyncRenderItemID, item.id);
|
||
assert.isFalse(await isPreviewDisplayed(attachmentsBox));
|
||
|
||
await waitForScrollToPane(itemDetails, paneID);
|
||
await waitForPreviewBoxRender(attachmentsBox);
|
||
// TEMP: wait for a bit to ensure the preview is rendered?
|
||
await Zotero.Promise.delay(100);
|
||
assert.isTrue(itemDetails.isPaneVisible(paneID));
|
||
assert.equal(attachmentsBox._syncRenderItemID, item.id);
|
||
assert.equal(attachmentsBox._asyncRenderItemID, item.id);
|
||
|
||
assert.isTrue(await isPreviewDisplayed(attachmentsBox));
|
||
assert.isTrue(preview.hasPreview);
|
||
win.resizeTo(null, height);
|
||
});
|
||
|
||
it("should update attachments pane when attachments changed", async function () {
|
||
// https://forums.zotero.org/discussion/113632/zotero-7-beta-pdf-attachment-preview-and-annotations-not-refreshed-after-adding-annotations
|
||
|
||
let itemDetails = ZoteroPane.itemPane._itemDetails;
|
||
let attachmentsBox = itemDetails.getPane(paneID);
|
||
let preview = attachmentsBox.previewElem;
|
||
// Force discard previous preview
|
||
await preview.discard(true);
|
||
|
||
// Pin the pane to ensure it's rendered
|
||
itemDetails.pinnedPane = paneID;
|
||
|
||
let item = new Zotero.Item('book');
|
||
await item.saveTx();
|
||
|
||
await ZoteroPane.selectItem(item.id);
|
||
// Scroll to the pane, since pinning doesn't reliably render it if it's out of view
|
||
await waitForScrollToPane(itemDetails, paneID);
|
||
// Pass the item id so we don't return early due to a previous item's completed render
|
||
assert.isTrue(await waitForPreviewBoxRender(attachmentsBox, item.id));
|
||
// No preview
|
||
assert.isFalse(await isPreviewDisplayed(attachmentsBox));
|
||
// No row
|
||
assert.equal(attachmentsBox.querySelectorAll("attachment-row").length, 0);
|
||
|
||
// Add an attachment
|
||
let file = getTestDataDirectory();
|
||
file.append('test.png');
|
||
let _attachment1 = await Zotero.Attachments.importFromFile({
|
||
file,
|
||
parentItemID: item.id
|
||
});
|
||
await ZoteroPane.selectItem(item.id);
|
||
await itemDetails._renderPromise;
|
||
await waitForPreviewBoxRender(attachmentsBox);
|
||
// Image preview for item with image attachment
|
||
assert.isTrue(await isPreviewDisplayed(attachmentsBox));
|
||
assert.equal(preview.previewType, "image");
|
||
// 1 row
|
||
assert.equal(attachmentsBox.querySelectorAll("attachment-row").length, 1);
|
||
|
||
// Add an PDF attachment, which will be best match and update the preview
|
||
file = getTestDataDirectory();
|
||
file.append('test.pdf');
|
||
let attachment2 = await Zotero.Attachments.importFromFile({
|
||
file,
|
||
parentItemID: item.id
|
||
});
|
||
await waitForPreviewBoxReader(attachmentsBox, attachment2.id);
|
||
await Zotero.Promise.delay(100);
|
||
// PDF preview
|
||
assert.isTrue(await isPreviewDisplayed(attachmentsBox));
|
||
assert.equal(preview.previewType, "pdf");
|
||
// 2 rows
|
||
assert.equal(attachmentsBox.querySelectorAll("attachment-row").length, 2);
|
||
// Simulate an extra 'add' event on the attachment - still 2 rows
|
||
attachmentsBox.notify('add', 'item', [attachment2.id]);
|
||
assert.equal(attachmentsBox.querySelectorAll("attachment-row").length, 2);
|
||
|
||
// Created annotations should be update in preview and attachment row
|
||
let annotation = await createAnnotation('highlight', attachment2);
|
||
await Zotero.Promise.delay(100);
|
||
// Annotation updated in preview reader
|
||
let readerAnnotation
|
||
= preview._reader._internalReader._annotationManager._annotations.find(
|
||
a => a.libraryID === annotation.libraryID && a.id === annotation.key
|
||
);
|
||
assert.exists(readerAnnotation);
|
||
|
||
assert.equal(attachmentsBox.querySelectorAll("attachment-row").length, 2);
|
||
let attachmentRow = attachmentsBox.querySelector(`attachment-row[attachment-id="${attachment2.id}"]`);
|
||
assert.isFalse(attachmentRow._annotationButton.hidden);
|
||
// 1 annotation
|
||
assert.equal(attachmentRow._annotationButton.querySelector('.label').textContent, "1");
|
||
|
||
// Deleted annotations should be removed from preview and attachment row
|
||
await annotation.eraseTx();
|
||
await Zotero.Promise.delay(100);
|
||
// Annotation removed from preview reader
|
||
readerAnnotation
|
||
= preview._reader._internalReader._annotationManager._annotations.find(
|
||
a => a.libraryID === annotation.libraryID && a.id === annotation.key
|
||
);
|
||
assert.notExists(readerAnnotation);
|
||
// Row might be recreated
|
||
attachmentRow = attachmentsBox.querySelector(`attachment-row[attachment-id="${attachment2.id}"]`);
|
||
assert.isTrue(attachmentRow._annotationButton.hidden);
|
||
// 0 annotation
|
||
assert.equal(attachmentRow._annotationButton.querySelector('.label').textContent, "0");
|
||
|
||
// Delete attachment
|
||
await attachment2.eraseTx();
|
||
await Zotero.Promise.delay(100);
|
||
// Image preview for item with image attachment
|
||
assert.isTrue(await isPreviewDisplayed(attachmentsBox));
|
||
assert.equal(preview.previewType, "image");
|
||
// 1 row
|
||
assert.equal(attachmentsBox.querySelectorAll("attachment-row").length, 1);
|
||
// The corresponding row should be removed
|
||
attachmentRow = attachmentsBox.querySelector(`attachment-row[attachment-id="${attachment2.id}"]`);
|
||
assert.notExists(attachmentRow);
|
||
|
||
// Unpin
|
||
itemDetails.pinnedPane = "";
|
||
itemDetails._paneParent.scrollTo(0, 0);
|
||
});
|
||
|
||
it("should keep attachments pane preview status after switching tab", async function () {
|
||
// https://forums.zotero.org/discussion/113658/zotero-7-beta-preview-appearing-in-the-item-pane-of-the-pdf-tab
|
||
|
||
let item = new Zotero.Item('book');
|
||
let file = getTestDataDirectory();
|
||
file.append('test.pdf');
|
||
await item.saveTx();
|
||
let attachment = await Zotero.Attachments.importFromFile({
|
||
file,
|
||
parentItemID: item.id
|
||
});
|
||
|
||
// Open reader
|
||
await ZoteroPane.viewItems([attachment]);
|
||
let tabID = Zotero_Tabs.selectedID;
|
||
await Zotero.Reader.getByTabID(tabID)._waitForReader();
|
||
// Ensure context pane is open
|
||
ZoteroContextPane.splitter.setAttribute("state", "open");
|
||
await waitForFrame();
|
||
|
||
let itemDetails = ZoteroContextPane.context._getItemContext(tabID);
|
||
let attachmentsBox = itemDetails.getPane(paneID);
|
||
assert.isFalse(attachmentsBox.hidden);
|
||
|
||
await waitForScrollToPane(itemDetails, paneID);
|
||
assert.isFalse(await isPreviewDisplayed(attachmentsBox));
|
||
|
||
// Select library tab
|
||
Zotero_Tabs.select("zotero-pane");
|
||
let libraryItemDetails = ZoteroPane.itemPane._itemDetails;
|
||
let libraryAttachmentsBox = libraryItemDetails.getPane(paneID);
|
||
await ZoteroPane.selectItem(item.id);
|
||
await waitForScrollToPane(libraryItemDetails, paneID);
|
||
// Collapse section
|
||
libraryAttachmentsBox.querySelector('collapsible-section > .head').click();
|
||
await Zotero.Promise.delay(50);
|
||
// Open section
|
||
libraryAttachmentsBox.querySelector('collapsible-section > .head').click();
|
||
await Zotero.Promise.delay(50);
|
||
|
||
// Select reader tab
|
||
Zotero_Tabs.select(tabID);
|
||
|
||
// Make sure the preview status is not changed in reader
|
||
assert.isFalse(await isPreviewDisplayed(attachmentsBox));
|
||
});
|
||
|
||
/**
|
||
* This test is essential to ensure the proper functioning of the sync/async rendering,
|
||
* scrolling handler, and pinning mechanism of ItemDetails.
|
||
* AttachmentsBox serves as a good example since it involves both sync and async rendering.
|
||
* If this test fails, it is not recommended to add timeouts as a quick fix.
|
||
*/
|
||
it("should keep attachments pane status after changing selection", async function () {
|
||
let itemDetails = ZoteroPane.itemPane._itemDetails;
|
||
let attachmentsBox = itemDetails.getPane(paneID);
|
||
let preview = attachmentsBox.previewElem;
|
||
|
||
// Pin the pane to avoid always scrolling to the section
|
||
itemDetails.pinnedPane = paneID;
|
||
|
||
// item with attachment (1 annotation)
|
||
let item1 = new Zotero.Item('book');
|
||
await item1.saveTx();
|
||
let file = getTestDataDirectory();
|
||
file.append('test.pdf');
|
||
let attachment1 = await Zotero.Attachments.importFromFile({
|
||
file,
|
||
parentItemID: item1.id
|
||
});
|
||
let annotation = await createAnnotation('highlight', attachment1);
|
||
|
||
await itemDetails._renderPromise;
|
||
// Scroll to the pane, since pinning doesn't reliably render it if it's out of view
|
||
await waitForScrollToPane(itemDetails, paneID);
|
||
await waitForPreviewBoxReader(attachmentsBox, attachment1.id);
|
||
|
||
assert.isFalse(attachmentsBox.hidden);
|
||
let readerAnnotation
|
||
= preview._reader._internalReader._annotationManager._annotations.find(
|
||
a => a.libraryID === annotation.libraryID && a.id === annotation.key
|
||
);
|
||
assert.exists(readerAnnotation);
|
||
assert.equal(attachmentsBox.querySelectorAll("attachment-row").length, 1);
|
||
let attachmentRow = attachmentsBox.querySelector(`attachment-row[attachment-id="${attachment1.id}"]`);
|
||
assert.isFalse(attachmentRow._annotationButton.hidden);
|
||
// 1 annotation
|
||
assert.equal(attachmentRow._annotationButton.querySelector('.label').textContent, "1");
|
||
|
||
// item with attachment (no annotation)
|
||
let item2 = new Zotero.Item('book');
|
||
await item2.saveTx();
|
||
file = getTestDataDirectory();
|
||
file.append('wonderland_short.pdf');
|
||
let attachment2 = await Zotero.Attachments.importFromFile({
|
||
file,
|
||
parentItemID: item2.id
|
||
});
|
||
|
||
// Select item with attachment (no annotation)
|
||
await itemDetails._renderPromise;
|
||
await waitForScrollToPane(itemDetails, paneID);
|
||
await waitForPreviewBoxReader(attachmentsBox, attachment2.id);
|
||
|
||
assert.isFalse(attachmentsBox.hidden);
|
||
readerAnnotation
|
||
= preview._reader._internalReader._annotationManager._annotations.find(
|
||
a => a.libraryID === annotation.libraryID && a.id === annotation.key
|
||
);
|
||
assert.notExists(readerAnnotation);
|
||
assert.equal(attachmentsBox.querySelectorAll("attachment-row").length, 1);
|
||
attachmentRow = attachmentsBox.querySelector(`attachment-row[attachment-id="${attachment2.id}"]`);
|
||
assert.isTrue(attachmentRow._annotationButton.hidden);
|
||
// 0 annotation
|
||
assert.equal(attachmentRow._annotationButton.querySelector('.label').textContent, "0");
|
||
|
||
let item3 = new Zotero.Item('book');
|
||
await item3.saveTx();
|
||
|
||
// Select item without attachment
|
||
await itemDetails._renderPromise;
|
||
await waitForScrollToPane(itemDetails, paneID);
|
||
|
||
assert.isFalse(attachmentsBox.hidden);
|
||
assert.equal(attachmentsBox.querySelectorAll("attachment-row").length, 0);
|
||
|
||
// Again, select item with attachment (1 annotation)
|
||
await ZoteroPane.selectItem(item1.id);
|
||
await itemDetails._renderPromise;
|
||
await waitForScrollToPane(itemDetails, paneID);
|
||
await waitForPreviewBoxReader(attachmentsBox, attachment1.id);
|
||
|
||
assert.isFalse(attachmentsBox.hidden);
|
||
readerAnnotation
|
||
= preview._reader._internalReader._annotationManager._annotations.find(
|
||
a => a.libraryID === annotation.libraryID && a.id === annotation.key
|
||
);
|
||
assert.exists(readerAnnotation);
|
||
assert.equal(attachmentsBox.querySelectorAll("attachment-row").length, 1);
|
||
attachmentRow = attachmentsBox.querySelector(`attachment-row[attachment-id="${attachment1.id}"]`);
|
||
assert.isFalse(attachmentRow._annotationButton.hidden);
|
||
// 1 annotation
|
||
assert.equal(attachmentRow._annotationButton.querySelector('.label').textContent, "1");
|
||
|
||
// Unpin
|
||
itemDetails.pinnedPane = "";
|
||
itemDetails._paneParent.scrollTo(0, 0);
|
||
});
|
||
|
||
it("should open attachment on clicking attachment row", async function () {
|
||
let itemDetails = ZoteroPane.itemPane._itemDetails;
|
||
let attachmentsBox = itemDetails.getPane(paneID);
|
||
|
||
let item = new Zotero.Item('book');
|
||
await item.saveTx();
|
||
let file = getTestDataDirectory();
|
||
file.append('test.pdf');
|
||
let attachment = await Zotero.Attachments.importFromFile({
|
||
file,
|
||
parentItemID: item.id
|
||
});
|
||
|
||
await ZoteroPane.selectItem(item.id);
|
||
await waitForScrollToPane(itemDetails, paneID);
|
||
await waitForPreviewBoxRender(attachmentsBox);
|
||
|
||
let attachmentRow = attachmentsBox.querySelector(`attachment-row[attachment-id="${attachment.id}"]`);
|
||
attachmentRow._attachmentButton.click();
|
||
await Zotero.Promise.delay(100);
|
||
let reader = await Zotero.Reader.getByTabID(Zotero_Tabs.selectedID);
|
||
// Should open attachment
|
||
assert.equal(reader.itemID, attachment.id);
|
||
});
|
||
|
||
it("should select attachment on clicking annotation button of attachment row", async function () {
|
||
let itemDetails = ZoteroPane.itemPane._itemDetails;
|
||
let attachmentsBox = itemDetails.getPane(paneID);
|
||
|
||
let item = new Zotero.Item('book');
|
||
await item.saveTx();
|
||
let file = getTestDataDirectory();
|
||
file.append('test.pdf');
|
||
let attachment = await Zotero.Attachments.importFromFile({
|
||
file,
|
||
parentItemID: item.id
|
||
});
|
||
let _annotation = await createAnnotation('highlight', attachment);
|
||
|
||
await ZoteroPane.selectItem(item.id);
|
||
await waitForScrollToPane(itemDetails, paneID);
|
||
await waitForPreviewBoxRender(attachmentsBox);
|
||
|
||
let attachmentRow = attachmentsBox.querySelector(`attachment-row[attachment-id="${attachment.id}"]`);
|
||
attachmentRow._annotationButton.click();
|
||
await Zotero.Promise.delay(100);
|
||
// Should select attachment
|
||
assert.equal(ZoteroPane.getSelectedItems(true)[0], attachment.id);
|
||
});
|
||
|
||
it("should open attachment on double-clicking attachments pane preview", async function () {
|
||
let itemDetails = ZoteroPane.itemPane._itemDetails;
|
||
let attachmentsBox = itemDetails.getPane(paneID);
|
||
let preview = attachmentsBox.previewElem;
|
||
|
||
let item = new Zotero.Item('book');
|
||
await item.saveTx();
|
||
let file = getTestDataDirectory();
|
||
file.append('test.pdf');
|
||
let attachment = await Zotero.Attachments.importFromFile({
|
||
file,
|
||
parentItemID: item.id
|
||
});
|
||
|
||
await ZoteroPane.selectItem(item.id);
|
||
await waitForScrollToPane(itemDetails, paneID);
|
||
await waitForPreviewBoxRender(attachmentsBox);
|
||
|
||
let event = new MouseEvent('dblclick', {
|
||
bubbles: true,
|
||
cancelable: true,
|
||
view: window
|
||
});
|
||
preview.dispatchEvent(event);
|
||
await Zotero.Promise.delay(100);
|
||
let reader = await Zotero.Reader.getByTabID(Zotero_Tabs.selectedID);
|
||
// Should open attachment
|
||
assert.equal(reader.itemID, attachment.id);
|
||
});
|
||
|
||
it("should render preview robustly after making dense calls to render and discard", async function () {
|
||
let itemDetails = ZoteroPane.itemPane._itemDetails;
|
||
let attachmentsBox = itemDetails.getPane(paneID);
|
||
let preview = attachmentsBox.previewElem;
|
||
|
||
// Pin the pane to avoid always scrolling to the section
|
||
itemDetails.pinnedPane = paneID;
|
||
|
||
// item with attachment
|
||
let item1 = new Zotero.Item('book');
|
||
await item1.saveTx();
|
||
let file1 = getTestDataDirectory();
|
||
file1.append('test.pdf');
|
||
let attachment1 = await Zotero.Attachments.importFromFile({
|
||
file: file1,
|
||
parentItemID: item1.id
|
||
});
|
||
|
||
let item2 = new Zotero.Item('book');
|
||
await item2.saveTx();
|
||
let file2 = getTestDataDirectory();
|
||
file2.append('test.pdf');
|
||
let attachment2 = await Zotero.Attachments.importFromFile({
|
||
file: file2,
|
||
parentItemID: item2.id
|
||
});
|
||
|
||
let selectionMap = [item1.id, item2.id];
|
||
// Repeat render/discard multiple times
|
||
for (let i = 0; i < 10; i++) {
|
||
await ZoteroPane.selectItem(selectionMap[i % 2]);
|
||
|
||
// No await, since the render/discard may be triggered at any time in actual usage
|
||
preview.discard();
|
||
preview.render();
|
||
}
|
||
|
||
// Wait for the last render/discard task to finish
|
||
await waitForCallback(() => !preview._isRendering && !preview._isDiscarding
|
||
&& !preview._isProcessingTask && !preview._isWaitingForTask
|
||
&& !preview._lastTask);
|
||
|
||
// Should be able to render the correct preview
|
||
await ZoteroPane.selectItem(item1.id);
|
||
// Scroll to the pane, since pinning doesn't reliably render it if it's out of view
|
||
await waitForScrollToPane(itemDetails, paneID);
|
||
await waitForPreviewBoxReader(attachmentsBox, attachment1.id);
|
||
assert.isTrue(await isPreviewDisplayed(attachmentsBox));
|
||
|
||
await ZoteroPane.selectItem(item2.id);
|
||
await waitForScrollToPane(itemDetails, paneID);
|
||
await waitForPreviewBoxReader(attachmentsBox, attachment2.id);
|
||
assert.isTrue(await isPreviewDisplayed(attachmentsBox));
|
||
|
||
itemDetails.pinnedPane = "";
|
||
itemDetails._paneParent.scrollTo(0, 0);
|
||
});
|
||
|
||
it("should not load preview iframe before becoming visible", async function () {
|
||
let itemDetails = ZoteroPane.itemPane._itemDetails;
|
||
let attachmentsBox = itemDetails.getPane(paneID);
|
||
|
||
// Resize to very small height to ensure the attachment box is not in view
|
||
let height = doc.documentElement.clientHeight;
|
||
win.resizeTo(null, 100);
|
||
|
||
let item = await createDataObject('item');
|
||
await importFileAttachment('test.pdf', { parentID: item.id });
|
||
|
||
await ZoteroPane.selectItem(item.id);
|
||
|
||
itemDetails._paneParent.scrollTo(0, 0);
|
||
|
||
// Remove any existing preview to ensure the test is valid
|
||
attachmentsBox._preview?.remove();
|
||
attachmentsBox._preview = null;
|
||
|
||
assert.notExists(attachmentsBox._preview);
|
||
assert.notExists(attachmentsBox.querySelector("#preview"));
|
||
|
||
await waitForScrollToPane(itemDetails, paneID);
|
||
await attachmentsBox._forceRenderAll();
|
||
await waitForPreviewBoxRender(attachmentsBox);
|
||
|
||
let preview = await getBoxPreview(attachmentsBox);
|
||
assert.exists(preview);
|
||
await preview._initPromise;
|
||
|
||
win.resizeTo(null, height);
|
||
});
|
||
|
||
it("should discard attachments pane preview after becoming invisible", async function () {
|
||
let itemDetails = ZoteroPane.itemPane._itemDetails;
|
||
let attachmentsBox = itemDetails.getPane(paneID);
|
||
|
||
const discardTimeout = 50;
|
||
|
||
// Temporarily set discard timeout for testing before any operations
|
||
let currentDiscardTimeout = attachmentsBox._discardPreviewTimeout;
|
||
attachmentsBox._discardPreviewTimeout = discardTimeout;
|
||
|
||
// Resize to very small height to ensure the attachment box is not in view
|
||
let height = doc.documentElement.clientHeight;
|
||
win.resizeTo(null, 100);
|
||
|
||
let item = await createDataObject('item');
|
||
let attachment = await importFileAttachment('test.pdf', { parentID: item.id });
|
||
|
||
await ZoteroPane.selectItem(item.id);
|
||
await waitForScrollToPane(itemDetails, paneID);
|
||
await waitForPreviewBoxReader(attachmentsBox, attachment.id);
|
||
|
||
assert.isTrue(attachmentsBox._preview._isReaderInitialized);
|
||
|
||
// Scroll the attachments pane out of view
|
||
await waitForScrollToPane(itemDetails, 'info');
|
||
|
||
// Wait for the intersection observer to trigger discard and the discard process to complete
|
||
await waitForCallback(() => !attachmentsBox._preview._isReaderInitialized);
|
||
|
||
assert.isFalse(attachmentsBox._preview._isReaderInitialized);
|
||
|
||
win.resizeTo(null, height);
|
||
attachmentsBox._discardPreviewTimeout = currentDiscardTimeout;
|
||
});
|
||
|
||
it("should update after attachment is trashed or restored", async function () {
|
||
// https://github.com/zotero/zotero/issues/4770
|
||
|
||
let itemDetails = ZoteroPane.itemPane._itemDetails;
|
||
let attachmentsBox = itemDetails.getPane(paneID);
|
||
|
||
let item = await createDataObject('item');
|
||
let attachment = await importFileAttachment('test.pdf', { parentID: item.id });
|
||
|
||
function getAttachmentRow() {
|
||
return attachmentsBox.querySelector(`attachment-row[attachment-id="${attachment.id}"]`);
|
||
}
|
||
|
||
await ZoteroPane.selectItem(item.id);
|
||
await waitForScrollToPane(itemDetails, paneID);
|
||
await waitForPreviewBoxRender(attachmentsBox);
|
||
|
||
// Trash the attachment
|
||
let trashPromise = waitForNotifierEvent('trash', 'item');
|
||
await Zotero.Items.trashTx([attachment.id]);
|
||
await trashPromise;
|
||
|
||
// Wait for the attachment row to be hidden
|
||
await waitForCallback(
|
||
() => getAttachmentRow().hidden
|
||
, 100, 3);
|
||
assert.isTrue(getAttachmentRow().hidden);
|
||
|
||
// Restore the attachment
|
||
let restorePromise = waitForNotifierEvent('modify', 'item');
|
||
attachment.deleted = false;
|
||
await attachment.saveTx();
|
||
await restorePromise;
|
||
|
||
// Wait for the attachment row to exist and be visible
|
||
await waitForCallback(
|
||
() => getAttachmentRow()?.hidden === false
|
||
, 100, 3);
|
||
assert.exists(getAttachmentRow());
|
||
assert.isFalse(getAttachmentRow().hidden);
|
||
|
||
// Basically, our item pane render mechanism will reuse the previous render if the item
|
||
// is the same. We want to ensure the attachments box is rerendered after
|
||
// the attachments' trash/restore, even if it's already rendered with the same item.
|
||
trashPromise = waitForNotifierEvent('trash', 'item');
|
||
await Zotero.Items.trashTx([attachment.id]);
|
||
await trashPromise;
|
||
|
||
// Wait for the attachment row to be hidden
|
||
await waitForCallback(
|
||
() => getAttachmentRow().hidden
|
||
, 100, 3);
|
||
|
||
// Select another non-regular item to ensure the box is not updated by notifier events
|
||
// At this point, the box still has the previous render with attachment row hidden
|
||
let item2 = await createDataObject('item');
|
||
let attachment2 = await importFileAttachment('test.pdf', { parentID: item2.id });
|
||
await ZoteroPane.selectItem(attachment2.id);
|
||
await waitForPreviewBoxRender(itemDetails.getPane("attachment-info"));
|
||
|
||
// Restore the attachment
|
||
restorePromise = waitForNotifierEvent('modify', 'item');
|
||
attachment.deleted = false;
|
||
await attachment.saveTx();
|
||
await restorePromise;
|
||
|
||
// Select the item with the restored attachment. A rerender should be triggered
|
||
await ZoteroPane.selectItem(item.id);
|
||
await waitForScrollToPane(itemDetails, paneID);
|
||
await waitForPreviewBoxRender(attachmentsBox);
|
||
|
||
// Wait for the attachment row to exist and be visible
|
||
await waitForCallback(
|
||
() => {
|
||
let row = getAttachmentRow();
|
||
window.console.log(row);
|
||
return row && !row.hidden;
|
||
}
|
||
, 100, 3);
|
||
|
||
// Should render the attachment row, as the render dependency is different
|
||
assert.exists(getAttachmentRow());
|
||
});
|
||
});
|
||
|
||
|
||
describe("Notes pane", function () {
|
||
it("should refresh on child note change", async function () {
|
||
var item;
|
||
var note1;
|
||
var note2;
|
||
await Zotero.DB.executeTransaction(async function () {
|
||
item = createUnsavedDataObject('item');
|
||
await item.save();
|
||
|
||
note1 = new Zotero.Item('note');
|
||
note1.parentID = item.id;
|
||
note1.setNote('A');
|
||
await note1.save();
|
||
|
||
note2 = new Zotero.Item('note');
|
||
note2.parentID = item.id;
|
||
note2.setNote('B');
|
||
await note2.save();
|
||
});
|
||
|
||
var body = doc.querySelector('#zotero-editpane-notes .body');
|
||
// Wait for note list to update
|
||
do {
|
||
await Zotero.Promise.delay(1);
|
||
}
|
||
while (body.querySelectorAll('.row .label').length !== 2);
|
||
|
||
// Update note text
|
||
note2.setNote('C');
|
||
await note2.saveTx();
|
||
|
||
// Wait for note list to update
|
||
do {
|
||
await Zotero.Promise.delay(1);
|
||
}
|
||
while ([...body.querySelectorAll('.row .label')].every(label => label.textContent != 'C'));
|
||
});
|
||
|
||
it("should refresh on child note trash", async function () {
|
||
var item;
|
||
var note1;
|
||
var note2;
|
||
await Zotero.DB.executeTransaction(async function () {
|
||
item = createUnsavedDataObject('item');
|
||
await item.save();
|
||
|
||
note1 = new Zotero.Item('note');
|
||
note1.parentID = item.id;
|
||
note1.setNote('A');
|
||
await note1.save();
|
||
|
||
note2 = new Zotero.Item('note');
|
||
note2.parentID = item.id;
|
||
note2.setNote('B');
|
||
await note2.save();
|
||
});
|
||
|
||
var body = doc.querySelector('#zotero-editpane-notes .body');
|
||
// Wait for note list to update
|
||
do {
|
||
await Zotero.Promise.delay(1);
|
||
}
|
||
while (body.querySelectorAll('.row .label').length !== 2);
|
||
|
||
// Click "-" in first note
|
||
var promise = waitForDialog();
|
||
body.querySelector(".zotero-clicky-minus").click();
|
||
await promise;
|
||
|
||
// Wait for note list to update
|
||
do {
|
||
await Zotero.Promise.delay(1);
|
||
}
|
||
while (body.querySelectorAll('.row .label').length !== 1);
|
||
});
|
||
|
||
it("should refresh on child note delete", async function () {
|
||
var item;
|
||
var note1;
|
||
var note2;
|
||
await Zotero.DB.executeTransaction(async function () {
|
||
item = createUnsavedDataObject('item');
|
||
await item.save();
|
||
|
||
note1 = new Zotero.Item('note');
|
||
note1.parentID = item.id;
|
||
note1.setNote('A');
|
||
await note1.save();
|
||
|
||
note2 = new Zotero.Item('note');
|
||
note2.parentID = item.id;
|
||
note2.setNote('B');
|
||
await note2.save();
|
||
});
|
||
|
||
var body = doc.querySelector('#zotero-editpane-notes .body');
|
||
// Wait for note list to update
|
||
do {
|
||
await Zotero.Promise.delay(1);
|
||
}
|
||
while (body.querySelectorAll('.row .label').length !== 2);
|
||
|
||
await note2.eraseTx();
|
||
|
||
// Wait for note list to update
|
||
do {
|
||
await Zotero.Promise.delay(1);
|
||
}
|
||
while (body.querySelectorAll('.row .label').length !== 1);
|
||
});
|
||
});
|
||
|
||
|
||
describe("Attachment pane", function () {
|
||
let paneID = "attachment-info";
|
||
|
||
beforeEach(function () {
|
||
Zotero.Prefs.set("panes.attachment-info.open", true);
|
||
Zotero.Prefs.set("showAttachmentPreview", true);
|
||
Zotero_Tabs.select("zotero-pane");
|
||
});
|
||
|
||
afterEach(function () {
|
||
// Ensure all previews are properly discarded and cleaned up
|
||
let itemDetails = ZoteroPane.itemPane._itemDetails;
|
||
let attachmentBox = itemDetails.getPane(paneID);
|
||
|
||
// Force cleanup of any pending operations and queued tasks
|
||
if (attachmentBox._preview) {
|
||
attachmentBox._preview._clearPendingTasks();
|
||
attachmentBox._preview.discard?.();
|
||
}
|
||
|
||
Zotero_Tabs.select("zotero-pane");
|
||
Zotero_Tabs.closeAll();
|
||
});
|
||
|
||
it("should refresh on file rename", async function () {
|
||
let file = getTestDataDirectory();
|
||
file.append('test.png');
|
||
let item = await Zotero.Attachments.importFromFile({
|
||
file: file
|
||
});
|
||
let newName = 'test2.png';
|
||
|
||
let itemBox = doc.getElementById('zotero-attachment-box');
|
||
let label = itemBox._id('fileName');
|
||
let promise = waitForDOMAttributes(label, 'value', (newValue) => {
|
||
return newValue === newName;
|
||
});
|
||
|
||
await item.renameAttachmentFile(newName);
|
||
|
||
await promise;
|
||
let box = ZoteroPane.itemPane._itemDetails.getPane(paneID);
|
||
await waitForPreviewBoxRender(box, item.id);
|
||
assert.equal(label.value, newName);
|
||
});
|
||
|
||
it("should update on attachment title change", async function () {
|
||
let file = getTestDataDirectory();
|
||
file.append('test.png');
|
||
let item = await Zotero.Attachments.importFromFile({ file });
|
||
let newTitle = 'New Title';
|
||
|
||
let paneHeader = doc.getElementById('zotero-item-pane-header');
|
||
let label = paneHeader.titleField;
|
||
let promise = Promise.all([
|
||
waitForDOMAttributes(label, 'value', (newValue) => {
|
||
return newValue === newTitle;
|
||
}),
|
||
waitForItemEvent('modify')
|
||
]);
|
||
|
||
item.setField('title', newTitle);
|
||
await item.saveTx();
|
||
|
||
await promise;
|
||
|
||
// Wait for section to finish rendering
|
||
let box = ZoteroPane.itemPane._itemDetails.getPane(paneID);
|
||
await waitForPreviewBoxRender(box, item.id);
|
||
|
||
assert.equal(label.value, newTitle);
|
||
});
|
||
|
||
it("should show attachment pane in library for attachment item", async function () {
|
||
// Regular item: hide
|
||
let itemDetails = ZoteroPane.itemPane._itemDetails;
|
||
let box = itemDetails.getPane(paneID);
|
||
|
||
// TEMP: Force abort any pending renders
|
||
box._preview?.remove();
|
||
box._preview = null;
|
||
|
||
let item = new Zotero.Item('book');
|
||
await item.saveTx();
|
||
await ZoteroPane.selectItem(item.id);
|
||
await waitForScrollToPane(itemDetails, paneID);
|
||
assert.isTrue(box.hidden);
|
||
|
||
// Child attachment: show
|
||
let file = getTestDataDirectory();
|
||
file.append('test.pdf');
|
||
let attachment = await Zotero.Attachments.importFromFile({
|
||
file,
|
||
parentItemID: item.id
|
||
});
|
||
await ZoteroPane.selectItem(attachment.id);
|
||
await waitForScrollToPane(itemDetails, paneID);
|
||
await waitForPreviewBoxReader(box, attachment.id);
|
||
assert.isFalse(box.hidden);
|
||
await Zotero.Promise.delay(100);
|
||
assert.isTrue(await isPreviewDisplayed(box));
|
||
|
||
// Standalone attachment: show
|
||
let attachment1 = await importFileAttachment('test.pdf');
|
||
await ZoteroPane.selectItem(attachment1.id);
|
||
await waitForScrollToPane(itemDetails, paneID);
|
||
await waitForPreviewBoxReader(box, attachment1.id);
|
||
assert.isFalse(box.hidden);
|
||
await Zotero.Promise.delay(100);
|
||
assert.isTrue(await isPreviewDisplayed(box));
|
||
});
|
||
|
||
it("should show attachment pane without preview in reader for standalone attachment item", async function () {
|
||
// Attachment item with parent item: hide
|
||
let item = new Zotero.Item('book');
|
||
let file = getTestDataDirectory();
|
||
file.append('test.pdf');
|
||
await item.saveTx();
|
||
let attachment = await Zotero.Attachments.importFromFile({
|
||
file,
|
||
parentItemID: item.id
|
||
});
|
||
await ZoteroPane.viewItems([attachment]);
|
||
let tabID = Zotero_Tabs.selectedID;
|
||
let itemDetails = ZoteroContextPane.context._getItemContext(tabID);
|
||
let box = itemDetails.getPane(paneID);
|
||
assert.isTrue(box.hidden);
|
||
|
||
// Standalone attachment item: show
|
||
attachment = await importFileAttachment('test.pdf');
|
||
await ZoteroPane.viewItems([attachment]);
|
||
tabID = Zotero_Tabs.selectedID;
|
||
itemDetails = ZoteroContextPane.context._getItemContext(tabID);
|
||
box = itemDetails.getPane(paneID);
|
||
assert.isFalse(box.hidden);
|
||
|
||
await waitForScrollToPane(itemDetails, paneID);
|
||
// No preview
|
||
assert.isFalse(await isPreviewDisplayed(box));
|
||
});
|
||
|
||
it("should only show attachment note container when exists", async function () {
|
||
let itemDetails = ZoteroPane.itemPane._itemDetails;
|
||
let box = itemDetails.getPane(paneID);
|
||
let noteContainer = box._id("note-container");
|
||
let noteEditor = box._id('attachment-note-editor');
|
||
|
||
// Hide note container by default
|
||
let attachment = await importFileAttachment('test.pdf');
|
||
await ZoteroPane.selectItem(attachment.id);
|
||
await itemDetails._renderPromise;
|
||
await waitForScrollToPane(itemDetails, paneID);
|
||
await waitForPreviewBoxRender(box);
|
||
assert.isTrue(noteContainer.hidden);
|
||
|
||
// Add attachment note
|
||
let itemModifyPromise = waitForItemEvent("modify");
|
||
attachment.setNote("<h1>TEST</h1>");
|
||
await attachment.saveTx();
|
||
await itemModifyPromise;
|
||
await waitForPreviewBoxRender(box);
|
||
// Should show note container
|
||
assert.isFalse(noteContainer.hidden);
|
||
// Should be readonly
|
||
assert.equal(noteEditor.mode, "view");
|
||
});
|
||
|
||
it("should discard attachment pane preview after becoming invisible", async function () {
|
||
// TEMP: https://github.com/zotero/zotero/issues/5624
|
||
if (Zotero.automatedTest) {
|
||
this.skip();
|
||
return;
|
||
}
|
||
let itemDetails = ZoteroPane.itemPane._itemDetails;
|
||
let attachmentBox = itemDetails.getPane(paneID);
|
||
|
||
const discardTimeout = 50;
|
||
|
||
// Temporarily set discard timeout for testing before any operations
|
||
let currentDiscardTimeout = attachmentBox._discardPreviewTimeout;
|
||
attachmentBox._discardPreviewTimeout = discardTimeout;
|
||
|
||
let attachment = await importFileAttachment('test.pdf');
|
||
|
||
await ZoteroPane.selectItem(attachment.id);
|
||
await waitForScrollToPane(itemDetails, paneID);
|
||
await waitForPreviewBoxReader(attachmentBox, attachment.id);
|
||
|
||
assert.isTrue(attachmentBox._preview._isReaderInitialized);
|
||
|
||
// Scroll the attachments pane out of view
|
||
await waitForScrollToPane(itemDetails, 'related');
|
||
|
||
// Wait for the intersection observer to trigger discard and the discard process to complete
|
||
await waitForCallback(() => !attachmentBox._preview?._isReaderInitialized);
|
||
|
||
assert.isFalse(!!attachmentBox._preview?._isReaderInitialized);
|
||
|
||
attachmentBox._discardPreviewTimeout = currentDiscardTimeout;
|
||
});
|
||
|
||
it("should not transfer focused title while switching between items", async function () {
|
||
let item = new Zotero.Item('book');
|
||
let attachmentOne = await importFileAttachment('test.pdf', { title: 'PDF_one', parentItemID: item.id });
|
||
let attachmentTwo = await importFileAttachment('test.pdf', { title: 'PDF_two', parentItemID: item.id });
|
||
await ZoteroPane.selectItem(attachmentOne.id);
|
||
|
||
let itemDetails = ZoteroPane.itemPane._itemDetails;
|
||
let attachmentBox = itemDetails.getPane(paneID);
|
||
|
||
attachmentBox.querySelector("#title").focus();
|
||
await ZoteroPane.selectItem(attachmentTwo.id);
|
||
await waitForNotifierEvent('modify', 'item');
|
||
assert.equal(attachmentTwo.getDisplayTitle(), "PDF_two");
|
||
});
|
||
|
||
it("should relate note and its attachment after being converted from embedded to standalone", async function () {
|
||
let collection = await createDataObject('collection');
|
||
let attachment = await importFileAttachment('test.pdf', { title: 'PDF_one' });
|
||
attachment.setNote("Embedded test note");
|
||
await attachment.saveTx();
|
||
await ZoteroPane.selectItem(attachment.id);
|
||
|
||
let itemDetails = ZoteroPane.itemPane._itemDetails;
|
||
let attachmentBox = itemDetails.getPane(paneID);
|
||
|
||
await attachmentBox.convertAttachmentNote();
|
||
// Ensure that the standalone note is recorded as a related item of the attachment
|
||
let relatedItems = attachment.relatedItems.map(key => Zotero.Items.getByLibraryAndKey(attachment.libraryID, key));
|
||
assert.lengthOf(relatedItems, 1);
|
||
// Ensure that the note has the correct content
|
||
let note = relatedItems[0];
|
||
assert.isTrue(note.isNote());
|
||
assert.equal(note.getNote(), "Embedded test note");
|
||
// The note should be added to the same collection(s) as the attachment
|
||
assert.sameMembers(attachment.getCollections(), note.getCollections());
|
||
});
|
||
});
|
||
|
||
|
||
describe("File renaming", function () {
|
||
before(function () {
|
||
Zotero.Prefs.set("panes.attachment-info.open", true);
|
||
});
|
||
|
||
it("should hide the rename from parent button if already renamed", async function () {
|
||
let item = await createDataObject('item', { title: 'Lorem Ipsum' });
|
||
let file = getTestDataDirectory();
|
||
file.append('test.pdf');
|
||
let attachment = await Zotero.Attachments.importFromFile({
|
||
file: file,
|
||
fileBaseName: "Lorem Ipsum", // Simulate auto-renaming, normally code would call getRenamedFileBaseNameIfAllowedType to generate fileBaseName
|
||
parentItemID: item.id
|
||
});
|
||
|
||
let zp = win.ZoteroPane;
|
||
await zp.selectItems([attachment.id]);
|
||
|
||
let itemBox = doc.getElementById('zotero-attachment-box');
|
||
let itemDetails = ZoteroPane.itemPane._itemDetails;
|
||
await zp.selectItems([attachment.id]);
|
||
await itemDetails._renderPromise;
|
||
|
||
let label = itemBox._id('fileName');
|
||
let button = itemBox._id('rename-from-parent');
|
||
|
||
// File is auto-renamed during import, button should be hidden
|
||
assert.isTrue(button.hidden);
|
||
assert.equal(label.value, "Lorem Ipsum.pdf");
|
||
|
||
await attachment.eraseTx();
|
||
await item.eraseTx();
|
||
});
|
||
|
||
it("should hide the rename from parent button, after file was renamed", async function () {
|
||
let item = await createDataObject('item', { title: 'Lorem Ipsum' });
|
||
let file = getTestDataDirectory();
|
||
file.append('test.txt');
|
||
let attachment = await Zotero.Attachments.importFromFile({
|
||
file: file,
|
||
parentItemID: item.id
|
||
});
|
||
|
||
let zp = win.ZoteroPane;
|
||
let itemBox = doc.getElementById('zotero-attachment-box');
|
||
let label = itemBox._id('fileName');
|
||
let button = itemBox._id('rename-from-parent');
|
||
let itemDetails = ZoteroPane.itemPane._itemDetails;
|
||
await zp.selectItems([attachment.id]);
|
||
await itemDetails._renderPromise;
|
||
assert.isFalse(button.hidden);
|
||
|
||
button.click();
|
||
assert.equal(await waitForItemEvent('modify'), attachment.id);
|
||
assert.equal(await waitForItemEvent('modify'), attachment.id);
|
||
|
||
assert.isTrue(button.hidden);
|
||
assert.equal(label.value, "Lorem Ipsum.txt");
|
||
|
||
await attachment.eraseTx();
|
||
await item.eraseTx();
|
||
});
|
||
|
||
it("should hide the rename from parent button for top-level items", async function () {
|
||
let file = getTestDataDirectory();
|
||
file.append('test.pdf');
|
||
let topLevelAttachment = await Zotero.Attachments.importFromFile({
|
||
file: file,
|
||
});
|
||
|
||
let zp = win.ZoteroPane;
|
||
let itemBox = doc.getElementById('zotero-attachment-box');
|
||
let button = itemBox._id('rename-from-parent');
|
||
let itemDetails = ZoteroPane.itemPane._itemDetails;
|
||
await zp.selectItems([topLevelAttachment.id]);
|
||
await itemDetails._renderPromise;
|
||
|
||
assert.isTrue(button.hidden);
|
||
await topLevelAttachment.eraseTx();
|
||
});
|
||
|
||
it("should show rename-from-parent button after parent metadata changes", async function () {
|
||
// Disable auto-rename so that changing parent metadata does not auto-rename the file
|
||
let origAutoRename = Zotero.Prefs.get('autoRenameFiles.onMetadataChange');
|
||
Zotero.Prefs.set('autoRenameFiles.onMetadataChange', false);
|
||
|
||
try {
|
||
// Create an item and an attachment whose filename already matches the template
|
||
let item = await createDataObject('item', { title: 'Lorem Ipsum' });
|
||
let file = getTestDataDirectory();
|
||
file.append('test.pdf');
|
||
let attachment = await Zotero.Attachments.importFromFile({
|
||
file: file,
|
||
fileBaseName: "Lorem Ipsum",
|
||
parentItemID: item.id
|
||
});
|
||
|
||
let zp = win.ZoteroPane;
|
||
let itemBox = doc.getElementById('zotero-attachment-box');
|
||
let button = itemBox._id('rename-from-parent');
|
||
let itemDetails = ZoteroPane.itemPane._itemDetails;
|
||
|
||
// Select the attachment -- button should be hidden since filename matches
|
||
await zp.selectItems([attachment.id]);
|
||
await itemDetails._renderPromise;
|
||
assert.isTrue(button.hidden, "button should be hidden when filename matches parent");
|
||
|
||
// Select the parent item and change its title without saving
|
||
await zp.selectItems([item.id]);
|
||
await itemDetails._renderPromise;
|
||
item.setField('title', 'Changed Title');
|
||
|
||
// Switch back to the attachment before parent save completes.
|
||
// This simulates the race between blur-triggered saveTx()
|
||
// and the item details pane rendering for the newly selected attachment.
|
||
await zp.selectItems([attachment.id]);
|
||
await itemDetails._renderPromise;
|
||
|
||
// Now save the parent -- the notify will fire after the attachment box
|
||
// has already rendered (and skipped re-render because deps matched cache)
|
||
await item.saveTx();
|
||
// Wait for the notifier-triggered async updateInfo() to complete
|
||
await waitForCallback(() => !button.hidden);
|
||
|
||
// The button should now be visible since the filename no longer matches
|
||
assert.isFalse(button.hidden, "button should be visible after parent title changed");
|
||
|
||
await attachment.eraseTx();
|
||
await item.eraseTx();
|
||
}
|
||
finally {
|
||
Zotero.Prefs.set('autoRenameFiles.onMetadataChange', origAutoRename);
|
||
}
|
||
});
|
||
});
|
||
|
||
|
||
describe("Note editor", function () {
|
||
it("should refresh on note update", async function () {
|
||
var item = new Zotero.Item('note');
|
||
var id = await item.saveTx();
|
||
|
||
var noteEditor = doc.getElementById('zotero-note-editor');
|
||
|
||
// Wait for the editor if it has not been initialized yet
|
||
if (!noteEditor.item) {
|
||
await new Zotero.Promise((resolve, reject) => {
|
||
noteEditor.onInit(() => resolve());
|
||
});
|
||
assert.equal(noteEditor._editorInstance._iframeWindow.wrappedJSObject.getDataSync(), null);
|
||
}
|
||
|
||
item.setNote('<p>Test</p>');
|
||
await item.saveTx();
|
||
|
||
// Wait for asynchronous editor update
|
||
do {
|
||
await Zotero.Promise.delay(10);
|
||
} while (
|
||
!/<div data-schema-version=".*"><p>Test<\/p><\/div>/.test(
|
||
noteEditor._editorInstance._iframeWindow.wrappedJSObject.getDataSync().html.replace(/\n/g, '')
|
||
)
|
||
);
|
||
});
|
||
});
|
||
|
||
describe("Feed buttons", function () {
|
||
describe("Mark as Read/Unread", function () {
|
||
it("should change an item from unread to read", async function () {
|
||
var feed = await createFeed();
|
||
await select(win, feed);
|
||
|
||
var item = await createDataObject('feedItem', { libraryID: feed.libraryID });
|
||
|
||
// Skip timed mark-as-read
|
||
var stub = sinon.stub(win.ZoteroPane, 'startItemReadTimeout');
|
||
await select(win, item);
|
||
|
||
// Click "Mark as Read"
|
||
var promise = waitForItemEvent('modify');
|
||
var button = ZoteroPane.itemPane.getCurrentPane().querySelector('.feed-item-toggleRead-button');
|
||
assert.equal(button.label, Zotero.getString('pane.item.markAsRead'));
|
||
assert.isFalse(item.isRead);
|
||
button.click();
|
||
var ids = await promise;
|
||
|
||
assert.sameMembers(ids, [item.id]);
|
||
assert.isTrue(item.isRead);
|
||
// Button is re-created
|
||
button = ZoteroPane.itemPane.getCurrentPane().querySelector('.feed-item-toggleRead-button');
|
||
assert.equal(button.label, Zotero.getString('pane.item.markAsUnread'));
|
||
|
||
stub.restore();
|
||
});
|
||
|
||
|
||
it("should update label when state of an item changes", async function () {
|
||
let feed = await createFeed();
|
||
await selectLibrary(win, feed.libraryID);
|
||
await waitForItemsLoad(win);
|
||
|
||
var stub = sinon.stub(win.ZoteroPane, 'startItemReadTimeout');
|
||
var item = await createDataObject('feedItem', { libraryID: feed.libraryID });
|
||
// Skip timed mark-as-read
|
||
assert.ok(stub.called);
|
||
stub.restore();
|
||
item.isRead = true;
|
||
await item.saveTx();
|
||
|
||
let button = ZoteroPane.itemPane.getCurrentPane().querySelector('.feed-item-toggleRead-button');
|
||
|
||
assert.equal(button.label, Zotero.getString('pane.item.markAsUnread'));
|
||
await item.toggleRead(false);
|
||
// Button is re-created
|
||
button = ZoteroPane.itemPane.getCurrentPane().querySelector('.feed-item-toggleRead-button');
|
||
assert.equal(button.label, Zotero.getString('pane.item.markAsRead'));
|
||
});
|
||
});
|
||
});
|
||
|
||
describe("Duplicates Merge pane", function () {
|
||
// Same as test in itemsTest, but via UI, which makes a copy via toJSON()/fromJSON()
|
||
it("should transfer merge-tracking relations when merging two pairs into one item", async function () {
|
||
var item1 = await createDataObject('item', { title: 'A' });
|
||
var item2 = await createDataObject('item', { title: 'B' });
|
||
var item3 = await createDataObject('item', { title: 'C' });
|
||
var item4 = await createDataObject('item', { title: 'D' });
|
||
|
||
var uris = [item2, item3, item4].map(item => Zotero.URI.getItemURI(item));
|
||
|
||
var p;
|
||
|
||
var zp = win.ZoteroPane;
|
||
await zp.selectItems([item1.id, item2.id]);
|
||
zp.mergeSelectedItems();
|
||
p = waitForItemEvent('modify');
|
||
doc.getElementById('zotero-duplicates-merge-button').click();
|
||
await p;
|
||
|
||
assert.sameMembers(
|
||
item1.getRelations()[Zotero.Relations.replacedItemPredicate],
|
||
[uris[0]]
|
||
);
|
||
|
||
await zp.selectItems([item3.id, item4.id]);
|
||
zp.mergeSelectedItems();
|
||
p = waitForItemEvent('modify');
|
||
doc.getElementById('zotero-duplicates-merge-button').click();
|
||
await p;
|
||
|
||
assert.sameMembers(
|
||
item3.getRelations()[Zotero.Relations.replacedItemPredicate],
|
||
[uris[2]]
|
||
);
|
||
|
||
await zp.selectItems([item1.id, item3.id]);
|
||
zp.mergeSelectedItems();
|
||
p = waitForItemEvent('modify');
|
||
doc.getElementById('zotero-duplicates-merge-button').click();
|
||
await p;
|
||
|
||
// Remaining item should include all other URIs
|
||
assert.sameMembers(
|
||
item1.getRelations()[Zotero.Relations.replacedItemPredicate],
|
||
uris
|
||
);
|
||
});
|
||
});
|
||
|
||
describe("Item pane and tabs", function () {
|
||
it("should switch to the correct pane when switching tabs", async function () {
|
||
// https://github.com/zotero/zotero/issues/4531#issuecomment-2470874876
|
||
let attachment = await importFileAttachment('test.pdf');
|
||
Zotero_Tabs.closeAll();
|
||
Zotero_Tabs.add({
|
||
type: 'reader-unloaded',
|
||
title: "Reader",
|
||
index: 1,
|
||
data: {
|
||
itemID: attachment.id
|
||
},
|
||
});
|
||
Zotero_Tabs.jump(1);
|
||
Zotero_Tabs.jump(0);
|
||
await Zotero.Promise.delay(100);
|
||
// Should not show the context pane for the reader tab
|
||
assert.isTrue(ZoteroContextPane.splitter.hidden);
|
||
});
|
||
});
|
||
|
||
describe("Libraries pane", function () {
|
||
let paneID = "libraries-collections";
|
||
|
||
beforeEach(function () {
|
||
Zotero.Prefs.set("panes.libraries-collections.open", true);
|
||
Zotero_Tabs.select("zotero-pane");
|
||
});
|
||
|
||
afterEach(function () {
|
||
Zotero_Tabs.select("zotero-pane");
|
||
Zotero_Tabs.closeAll();
|
||
});
|
||
|
||
it("should scroll to pinned pane after selection moves from note to item", async function () {
|
||
let itemDetails = ZoteroPane.itemPane._itemDetails;
|
||
let pane = itemDetails.getPane(paneID);
|
||
|
||
let item = await createDataObject('item');
|
||
await importPDFAttachment(item);
|
||
|
||
let note = await createDataObject('item', { itemType: 'note' });
|
||
|
||
await select(win, item);
|
||
await waitForScrollToPane(itemDetails, paneID);
|
||
|
||
itemDetails.pinnedPane = paneID;
|
||
|
||
await select(win, note);
|
||
|
||
await Zotero.Promise.delay(10);
|
||
|
||
await select(win, item);
|
||
await waitForScrollToPane(itemDetails, paneID);
|
||
|
||
// Should scroll to pinned pane
|
||
assert.isTrue(
|
||
Math.abs(pane.getBoundingClientRect().top - pane.parentElement.getBoundingClientRect().top) < 3
|
||
);
|
||
|
||
itemDetails.pinnedPane = "";
|
||
itemDetails._paneParent.scrollTo(0, 0);
|
||
});
|
||
});
|
||
|
||
describe("Sidenav", function () {
|
||
async function waitForSidenav() {
|
||
await waitForCallback(() => {
|
||
return !!ZoteroPane.itemPane._itemDetails.sidenav?._initialized;
|
||
});
|
||
}
|
||
|
||
async function waitForSidenavActive() {
|
||
await waitForCallback(() => {
|
||
return !ZoteroPane.itemPane._itemDetails.sidenav._defaultStatus;
|
||
});
|
||
}
|
||
|
||
function compareOrder(order, targetOrder) {
|
||
let lastIndex = -1;
|
||
for (let paneID of order) {
|
||
let index = targetOrder.indexOf(paneID);
|
||
if (index === -1) {
|
||
continue;
|
||
}
|
||
if (index < lastIndex) {
|
||
return false;
|
||
}
|
||
lastIndex = index;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function compareButtonOrder(order) {
|
||
let wrappers = ZoteroPane.itemPane._itemDetails.sidenav._enabledWrappers;
|
||
let buttonOrder = wrappers.map(wrapper => wrapper.querySelector('.btn').dataset.pane);
|
||
return compareOrder(buttonOrder, order)
|
||
}
|
||
|
||
function compareSectionOrder(order) {
|
||
let sections = ZoteroPane.itemPane._itemDetails.getEnabledPanes();
|
||
let sectionOrder = sections.map(section => section.dataset.pane);
|
||
return compareOrder(sectionOrder, order)
|
||
}
|
||
|
||
function getSidenavOrder() {
|
||
return Zotero.Prefs.get('sidenav.order') || ZoteroPane.itemPane._itemDetails.sidenav._builtInPanes.join(',');
|
||
}
|
||
|
||
async function clickSidenavMenu(paneIdx, menuSelector) {
|
||
let sidenav = ZoteroPane.itemPane._itemDetails.sidenav;
|
||
if (paneIdx < 0) {
|
||
paneIdx = sidenav._enabledWrappers.length + paneIdx;
|
||
}
|
||
let btn = sidenav._enabledWrappers[paneIdx].querySelector('.btn');
|
||
let btnRect = btn.getBoundingClientRect();
|
||
let popup = sidenav.querySelector('.context-menu');
|
||
let promise = waitForDOMEvent(popup, 'popupshown');
|
||
sidenav.handleButtonContextMenu({
|
||
target: btn,
|
||
preventDefault: function () {},
|
||
screenX: btnRect.left,
|
||
screenY: btnRect.top,
|
||
});
|
||
await promise;
|
||
let menu = popup.querySelector(menuSelector);
|
||
if (!menu || menu.hidden) {
|
||
popup.hidePopup();
|
||
return false;
|
||
}
|
||
menu.click();
|
||
popup.hidePopup();
|
||
return true;
|
||
}
|
||
|
||
it("should reorder section when prefs change", async function () {
|
||
await waitForSidenav();
|
||
|
||
let orderRaw = getSidenavOrder();
|
||
let order = orderRaw.split(',');
|
||
let newOrder = order.reverse()
|
||
let newOrderRaw = newOrder.join(',');
|
||
Zotero.Prefs.set('sidenav.order', newOrderRaw);
|
||
|
||
// If the order is not updated, a timeout exception will be thrown to fail the test
|
||
await waitForCallback(() => {
|
||
return compareSectionOrder(newOrder) && compareButtonOrder(newOrder);
|
||
}
|
||
, 100, 3);
|
||
});
|
||
|
||
it("should move section up", async function () {
|
||
await waitForSidenav();
|
||
|
||
// Create an item so that the sidenav is active
|
||
let item = await createDataObject('item');
|
||
await ZoteroPane.selectItem(item.id);
|
||
|
||
await waitForSidenavActive();
|
||
|
||
let orderRaw = getSidenavOrder();
|
||
let order = orderRaw.split(',');
|
||
|
||
let promise = waitForPrefsChange('sidenav.order');
|
||
let menuEnabled = await clickSidenavMenu(1, '.zotero-menuitem-reorder-up');
|
||
assert.isTrue(menuEnabled);
|
||
await promise;
|
||
|
||
let newOrderRaw = getSidenavOrder();
|
||
|
||
let newOrder = newOrderRaw.split(',');
|
||
let expectedOrder = [...order];
|
||
// Exchange 0 and 1
|
||
let temp = expectedOrder[0];
|
||
expectedOrder[0] = expectedOrder[1];
|
||
expectedOrder[1] = temp;
|
||
assert.deepEqual(newOrder, expectedOrder);
|
||
|
||
// Remove the temp item
|
||
await Zotero.Items.erase(item.id);
|
||
});
|
||
|
||
it("should move section down", async function () {
|
||
await waitForSidenav();
|
||
|
||
// Create an item so that the sidenav is active
|
||
let item = await createDataObject('item');
|
||
await ZoteroPane.selectItem(item.id);
|
||
|
||
await waitForSidenavActive();
|
||
|
||
let orderRaw = getSidenavOrder();
|
||
let order = orderRaw.split(',');
|
||
let promise = waitForPrefsChange('sidenav.order');
|
||
let menuEnabled = await clickSidenavMenu(0, '.zotero-menuitem-reorder-down');
|
||
assert.isTrue(menuEnabled);
|
||
await promise;
|
||
|
||
let newOrderRaw = getSidenavOrder();
|
||
|
||
let newOrder = newOrderRaw.split(',');
|
||
let expectedOrder = [...order];
|
||
// Exchange 0 and 1
|
||
let temp = expectedOrder[0];
|
||
expectedOrder[0] = expectedOrder[1];
|
||
expectedOrder[1] = temp;
|
||
assert.deepEqual(newOrder, expectedOrder);
|
||
|
||
// Remove the temp item
|
||
await Zotero.Items.erase(item.id);
|
||
});
|
||
|
||
it("should not show move up menu for first section", async function () {
|
||
await waitForSidenav();
|
||
|
||
// Create an item so that the sidenav is active
|
||
let item = await createDataObject('item');
|
||
await ZoteroPane.selectItem(item.id);
|
||
|
||
await waitForSidenavActive();
|
||
|
||
let menuEnabled = await clickSidenavMenu(0, '.zotero-menuitem-reorder-up');
|
||
assert.isFalse(menuEnabled);
|
||
|
||
await Zotero.Items.erase(item.id);
|
||
});
|
||
|
||
it("should not show move down menu for last section", async function () {
|
||
await waitForSidenav();
|
||
|
||
// Create an item so that the sidenav is active
|
||
let item = await createDataObject('item');
|
||
await ZoteroPane.selectItem(item.id);
|
||
|
||
await waitForSidenavActive();
|
||
|
||
let menuEnabled = await clickSidenavMenu(-1, '.zotero-menuitem-reorder-down');
|
||
assert.isFalse(menuEnabled);
|
||
|
||
await Zotero.Items.erase(item.id);
|
||
});
|
||
|
||
it("should unpin section if it moves to the top", async function () {
|
||
await waitForSidenav();
|
||
|
||
// Create an item so that the sidenav is active
|
||
let item = await createDataObject('item');
|
||
await ZoteroPane.selectItem(item.id);
|
||
|
||
await waitForSidenavActive();
|
||
|
||
let orderRaw = getSidenavOrder();
|
||
let order = orderRaw.split(',');
|
||
|
||
// Pin the second section
|
||
ZoteroPane.itemPane._itemDetails.pinnedPane = order[1];
|
||
|
||
let promise = waitForPrefsChange('sidenav.order');
|
||
let menuEnabled = await clickSidenavMenu(1, '.zotero-menuitem-reorder-up');
|
||
assert.isTrue(menuEnabled);
|
||
await promise;
|
||
|
||
assert.isEmpty(ZoteroPane.itemPane._itemDetails.pinnedPane);
|
||
|
||
await Zotero.Items.erase(item.id);
|
||
});
|
||
|
||
it("should not show reorder menu for custom section with orderable disabled", async function () {
|
||
await waitForSidenav();
|
||
|
||
const registeredID = Zotero.ItemPaneManager.registerSection({
|
||
paneID: "custom-section-example",
|
||
pluginID: "example@example.com",
|
||
header: {
|
||
l10nID: "example-item-pane-header",
|
||
icon: "chrome://zotero/skin/16/universal/note.svg",
|
||
},
|
||
sidenav: {
|
||
l10nID: "example-item-pane-header",
|
||
icon: "chrome://zotero/skin/20/universal/note.svg",
|
||
// Disable orderable
|
||
orderable: false,
|
||
},
|
||
onRender: ({ body }) => {
|
||
body.textContent = "Custom section";
|
||
},
|
||
});
|
||
|
||
// Create an item so that the sidenav is active
|
||
let item = await createDataObject('item');
|
||
await ZoteroPane.selectItem(item.id);
|
||
|
||
await waitForSidenavActive();
|
||
|
||
let menuUpEnabled = await clickSidenavMenu(-1, '.zotero-menuitem-reorder-up');
|
||
assert.isFalse(menuUpEnabled);
|
||
let menuDownEnabled = await clickSidenavMenu(-1, '.zotero-menuitem-reorder-down');
|
||
assert.isFalse(menuDownEnabled);
|
||
|
||
await Zotero.Items.erase(item.id);
|
||
Zotero.ItemPaneManager.unregisterSection(registeredID);
|
||
});
|
||
});
|
||
|
||
describe("AnnotationItemsPane", function () {
|
||
it("should display selected annotations groupped by parent item", async () => {
|
||
let toplevelItemOne = await createDataObject('item', { title: "Item one" });
|
||
let attachmentOne = await importFileAttachment('test.pdf', { title: 'PDF', parentItemID: toplevelItemOne.id });
|
||
let highlightOne = await createAnnotation('highlight', attachmentOne);
|
||
|
||
let toplevelItemTwo = await createDataObject('item', { title: "Item two" });
|
||
let attachmentTwo = await importFileAttachment('test.pdf', { title: 'PDF', parentItemID: toplevelItemTwo.id });
|
||
let highlightTwo = await createAnnotation('highlight', attachmentTwo);
|
||
|
||
ZoteroPane.itemsView.expandAllRows(true);
|
||
|
||
await ZoteroPane.itemsView.selectItems([highlightOne.id, highlightTwo.id]);
|
||
|
||
let sections = [...win.document.querySelectorAll("annotation-items-pane collapsible-section")];
|
||
// Top level items' titles are in section summaries
|
||
assert.equal(sections[0].summary, toplevelItemOne.getDisplayTitle());
|
||
assert.equal(sections[1].summary, toplevelItemTwo.getDisplayTitle());
|
||
// Each item's section contains its annotation
|
||
assert.equal(sections[0].querySelector("annotation-row").annotation.id, highlightOne.id);
|
||
assert.equal(sections[1].querySelector("annotation-row").annotation.id, highlightTwo.id);
|
||
});
|
||
|
||
it("should refresh when annotation is updated", async () => {
|
||
let toplevelItemOne = await createDataObject('item', { title: "Item one" });
|
||
let attachmentOne = await importFileAttachment('test.pdf', { title: 'PDF', parentItemID: toplevelItemOne.id });
|
||
let highlightOne = await createAnnotation('highlight', attachmentOne);
|
||
|
||
highlightOne.annotationText = "Annotation";
|
||
await highlightOne.saveTx();
|
||
|
||
ZoteroPane.itemsView.expandAllRows(true);
|
||
await ZoteroPane.itemsView.selectItems([highlightOne.id]);
|
||
|
||
assert.equal(win.document.querySelector("annotation-items-pane annotation-row .quote").textContent, "Annotation");
|
||
highlightOne.annotationText = "Updated";
|
||
await highlightOne.saveTx();
|
||
assert.equal(win.document.querySelector("annotation-items-pane annotation-row .quote").textContent, "Updated");
|
||
});
|
||
});
|
||
|
||
describe("Collapsing", function () {
|
||
function isCollapsed() {
|
||
if (Zotero_Tabs.selectedType === 'reader') {
|
||
return ZoteroContextPane.collapsed;
|
||
}
|
||
else {
|
||
return ZoteroPane.itemPane.collapsed;
|
||
}
|
||
}
|
||
|
||
function getVisibleToggle() {
|
||
let isVisible = el => el
|
||
&& !!el.clientWidth
|
||
&& getComputedStyle(el).visibility === 'visible'
|
||
// Really over the top, but the visibility of deck descendants
|
||
// with a `display set in CSS seemingly can't be detected any
|
||
// way but this:
|
||
&& (!el.closest('deck') || el.closest('deck').selectedPanel === el.closest('deck > *'));
|
||
|
||
if (isVisible(doc.querySelector('#zotero-view-item-sidenav toolbarbutton[data-action="toggle-pane"]'))) {
|
||
return 'item pane sidenav';
|
||
}
|
||
|
||
if (isVisible(doc.querySelector('#zotero-context-pane-sidenav toolbarbutton[data-action="toggle-pane"]'))) {
|
||
return 'context pane sidenav';
|
||
}
|
||
|
||
if (isVisible(doc.querySelector('#zotero-tb-toggle-item-pane-stacked'))) {
|
||
return 'item tree toolbar';
|
||
}
|
||
|
||
if (
|
||
Zotero.Reader._readers.some(
|
||
r => isVisible(r._iframe.contentDocument?.querySelector('.toolbar-button.context-pane-toggle'))
|
||
)
|
||
) {
|
||
return 'reader toolbar';
|
||
}
|
||
|
||
return 'none';
|
||
}
|
||
|
||
async function waitForToggle(togglePosition) {
|
||
while (getVisibleToggle() !== togglePosition) {
|
||
await Zotero.Promise.delay(100);
|
||
}
|
||
}
|
||
|
||
let attachment;
|
||
|
||
before(async () => {
|
||
attachment = await importPDFAttachment();
|
||
});
|
||
|
||
beforeEach(() => {
|
||
// Make the window wide enough not to automatically enter Stacked mode
|
||
win.resizeTo(1000, 800);
|
||
Zotero.Prefs.set('layout', 'standard');
|
||
ZoteroPane.updateLayout();
|
||
|
||
Zotero_Tabs.select('zotero-pane');
|
||
ZoteroPane.itemPane.collapsed = false;
|
||
ZoteroContextPane.collapsed = true;
|
||
});
|
||
|
||
it("should initially show sidenav toggle", async function () {
|
||
assert.isFalse(isCollapsed());
|
||
assert.equal(getVisibleToggle(), 'item pane sidenav');
|
||
});
|
||
|
||
it("should still show sidenav toggle after collapse in library", function () {
|
||
assert.isFalse(isCollapsed());
|
||
ZoteroPane.itemPane.collapsed = true;
|
||
assert.isTrue(isCollapsed());
|
||
assert.equal(getVisibleToggle(), 'item pane sidenav');
|
||
});
|
||
|
||
it("should switch to item tree toolbar toggle in Stacked mode", function () {
|
||
assert.isFalse(isCollapsed());
|
||
Zotero.Prefs.set('layout', 'stacked');
|
||
assert.isFalse(isCollapsed());
|
||
assert.equal(getVisibleToggle(), 'item tree toolbar');
|
||
});
|
||
|
||
it("should remain visible in Stacked mode after collapsing", function () {
|
||
Zotero.Prefs.set('layout', 'stacked');
|
||
ZoteroPane.itemPane.collapsed = true;
|
||
assert.isTrue(isCollapsed());
|
||
assert.equal(getVisibleToggle(), 'item tree toolbar');
|
||
});
|
||
|
||
it("should keep collapsed state after switching from Standard to Stacked", function () {
|
||
assert.isFalse(isCollapsed());
|
||
ZoteroPane.itemPane.collapsed = true;
|
||
assert.isTrue(isCollapsed());
|
||
Zotero.Prefs.set('layout', 'stacked');
|
||
assert.isTrue(isCollapsed());
|
||
});
|
||
|
||
it("should keep collapsed state after switching from Stacked to Standard", function () {
|
||
Zotero.Prefs.set('layout', 'stacked');
|
||
assert.isFalse(isCollapsed());
|
||
ZoteroPane.itemPane.collapsed = true;
|
||
assert.isTrue(isCollapsed());
|
||
Zotero.Prefs.set('layout', 'standard');
|
||
assert.isTrue(isCollapsed());
|
||
});
|
||
|
||
it("should show in reader toolbar when collapsed in Standard mode", async function () {
|
||
await ZoteroPane.viewItems([attachment]);
|
||
await Zotero.Reader.getByTabID(Zotero_Tabs.selectedID)._waitForReader();
|
||
|
||
assert.isTrue(isCollapsed());
|
||
await waitForToggle('reader toolbar');
|
||
});
|
||
|
||
it("should show in reader sidenav when expanded in Standard mode", async function () {
|
||
await ZoteroPane.viewItems([attachment]);
|
||
await Zotero.Reader.getByTabID(Zotero_Tabs.selectedID)._waitForReader();
|
||
|
||
ZoteroContextPane.collapsed = false;
|
||
await waitForToggle('context pane sidenav');
|
||
});
|
||
|
||
it("should return to reader toolbar after collapsing in Standard mode", async function () {
|
||
await ZoteroPane.viewItems([attachment]);
|
||
await Zotero.Reader.getByTabID(Zotero_Tabs.selectedID)._waitForReader();
|
||
|
||
ZoteroContextPane.collapsed = false;
|
||
ZoteroContextPane.collapsed = true;
|
||
await waitForToggle('reader toolbar');
|
||
});
|
||
|
||
it("should show in reader toolbar when collapsed in Stacked mode", async function () {
|
||
await ZoteroPane.viewItems([attachment]);
|
||
await Zotero.Reader.getByTabID(Zotero_Tabs.selectedID)._waitForReader();
|
||
|
||
assert.isTrue(isCollapsed());
|
||
Zotero.Prefs.set('layout', 'stacked');
|
||
assert.isTrue(isCollapsed());
|
||
await waitForToggle('reader toolbar');
|
||
});
|
||
|
||
it("should stay in reader toolbar when expanded in Stacked mode", async function () {
|
||
await ZoteroPane.viewItems([attachment]);
|
||
await Zotero.Reader.getByTabID(Zotero_Tabs.selectedID)._waitForReader();
|
||
|
||
assert.isTrue(isCollapsed());
|
||
Zotero.Prefs.set('layout', 'stacked');
|
||
assert.isTrue(isCollapsed());
|
||
ZoteroContextPane.collapsed = false;
|
||
assert.isFalse(isCollapsed());
|
||
await waitForToggle('reader toolbar');
|
||
});
|
||
});
|
||
|
||
describe("Batch Edit", function () {
|
||
let createdItems = [];
|
||
let _createDataObject = async (...args) => {
|
||
let item = await createDataObject(...args);
|
||
createdItems.push(item);
|
||
return item;
|
||
};
|
||
afterEach(async function () {
|
||
for (let item of createdItems.reverse()) {
|
||
await item.eraseTx();
|
||
}
|
||
createdItems = [];
|
||
});
|
||
|
||
it("should enter and exit batch edit mode", async function () {
|
||
let item1 = await _createDataObject('item', { itemType: 'journalArticle' });
|
||
let item2 = await _createDataObject('item', { itemType: 'journalArticle' });
|
||
await ZoteroPane.selectItems([item1.id, item2.id]);
|
||
await waitForFrame();
|
||
|
||
let itemPane = win.ZoteroPane.itemPane;
|
||
let itemDetails = ZoteroPane.itemPane._itemDetails;
|
||
|
||
// Enter batch edit mode
|
||
assert.equal(itemPane.mode, "batch-edit-prompt");
|
||
let batchEditEnableBtn = doc.getElementById('batch-edit-prompt-enable');
|
||
assert.ok(batchEditEnableBtn, "batch edit enable button should exist");
|
||
batchEditEnableBtn.click();
|
||
await itemDetails._renderPromise;
|
||
|
||
// Should now be in item mode with batch editing enabled
|
||
assert.equal(itemPane.mode, "item");
|
||
|
||
// Header should hide title-head and show items selected label
|
||
let header = itemDetails._header;
|
||
assert.ok(header.classList.contains('no-title-head'), "title-head should be hidden in batch edit mode");
|
||
assert.ok(header.querySelector('[data-l10n-id="item-pane-batch-editing-header"]'), "batch editing header label should be in header");
|
||
|
||
// Exit batch edit mode by changing selection to a single item
|
||
await ZoteroPane.selectItem(item1.id);
|
||
await waitForFrame();
|
||
|
||
// Should be back in batch-edit-prompt mode when re-selecting both
|
||
await ZoteroPane.selectItems([item1.id, item2.id]);
|
||
await waitForFrame();
|
||
assert.equal(itemPane.mode, "batch-edit-prompt");
|
||
});
|
||
it("should restore collapsed info section state after exiting batch edit via selection change", async function () {
|
||
let item1 = await _createDataObject('item', { itemType: 'journalArticle' });
|
||
let item2 = await _createDataObject('item', { itemType: 'journalArticle' });
|
||
let itemPane = win.ZoteroPane.itemPane;
|
||
let itemDetails = ZoteroPane.itemPane._itemDetails;
|
||
|
||
// Select item1 and collapse the info section
|
||
await ZoteroPane.selectItem(item1.id);
|
||
await waitForFrame();
|
||
let infoSection = itemDetails.querySelector('collapsible-section[data-pane="info"]');
|
||
infoSection.open = false;
|
||
assert.isFalse(infoSection.open, "info section should be collapsed");
|
||
|
||
// Select both items to enter batch edit prompt
|
||
await ZoteroPane.selectItems([item1.id, item2.id]);
|
||
await waitForFrame();
|
||
assert.equal(itemPane.mode, "batch-edit-prompt");
|
||
|
||
// Enter batch edit mode
|
||
let batchEditEnableBtn = doc.getElementById('batch-edit-prompt-enable');
|
||
batchEditEnableBtn.click();
|
||
await itemDetails._renderPromise;
|
||
assert.equal(itemPane.mode, "item");
|
||
|
||
// Info section should be forced open with no twisty
|
||
infoSection = itemDetails.querySelector('collapsible-section[data-pane="info"]');
|
||
assert.isTrue(infoSection.open, "info section should be open in batch edit mode");
|
||
let twisty = infoSection.querySelector('.twisty');
|
||
assert.isTrue(twisty.hidden, "twisty should be hidden in batch edit mode");
|
||
|
||
// Change selection to just one item -- exits batch edit
|
||
await ZoteroPane.selectItem(item1.id);
|
||
await waitForFrame();
|
||
|
||
// Info section should restore its previous collapsed state
|
||
infoSection = itemDetails.querySelector('collapsible-section[data-pane="info"]');
|
||
assert.isFalse(infoSection.open, "info section should be collapsed after exiting batch edit");
|
||
twisty = infoSection.querySelector('.twisty');
|
||
assert.isFalse(twisty.hidden, "twisty should be visible after exiting batch edit");
|
||
});
|
||
it("should apply autocomplete value to all items in batch edit mode", async function () {
|
||
let sharedTitle = "Journal of Shared Research";
|
||
let differentTitle = "Journal of Different Research";
|
||
|
||
let item1 = await _createDataObject('item', { itemType: 'journalArticle' });
|
||
item1.setField('publicationTitle', sharedTitle);
|
||
await item1.saveTx();
|
||
|
||
let item2 = await _createDataObject('item', { itemType: 'journalArticle' });
|
||
item2.setField('publicationTitle', sharedTitle);
|
||
await item2.saveTx();
|
||
|
||
let item3 = await _createDataObject('item', { itemType: 'journalArticle' });
|
||
item3.setField('publicationTitle', differentTitle);
|
||
await item3.saveTx();
|
||
|
||
let item4 = await _createDataObject('item', { itemType: 'journalArticle' });
|
||
await item4.saveTx();
|
||
|
||
await ZoteroPane.selectItems([item1.id, item2.id, item3.id, item4.id]);
|
||
|
||
let itemPane = win.ZoteroPane.itemPane;
|
||
let itemDetails = ZoteroPane.itemPane._itemDetails;
|
||
|
||
let batchEditEnableBtn = doc.getElementById('batch-edit-prompt-enable');
|
||
batchEditEnableBtn.click();
|
||
await itemDetails._renderPromise;
|
||
|
||
let itemBox = itemPane.querySelector('#zotero-editpane-info-box');
|
||
let pubTitleField = itemBox.querySelector('editable-text[fieldname="publicationTitle"]');
|
||
assert.ok(pubTitleField, "publicationTitle field should exist");
|
||
|
||
pubTitleField._ignoredWindowInactiveBlur = false;
|
||
await activateZoteroPane();
|
||
await Zotero.Promise.delay(50);
|
||
pubTitleField.focus();
|
||
|
||
// 2 value options + 1 "no value" option
|
||
await waitForCallback(() => pubTitleField.ref.mController.matchCount === 3, 100, 10);
|
||
let controller = pubTitleField.ref.mController;
|
||
// Options are sorted by frequency (most common first); empty values are ignored
|
||
assert.equal(controller.matchCount, 3);
|
||
assert.equal(controller.getValueAt(0), sharedTitle);
|
||
assert.equal(controller.getFinalCompleteValueAt(0), sharedTitle);
|
||
assert.equal(controller.getValueAt(1), differentTitle);
|
||
assert.equal(controller.getFinalCompleteValueAt(1), differentTitle);
|
||
// Last option should be "no value"
|
||
assert.equal(controller.getStyleAt(2), 'options-ac-no-value');
|
||
|
||
let modifyPromise = waitForItemEvent('modify');
|
||
pubTitleField.ref.dispatchEvent(new KeyboardEvent(
|
||
'keydown', { key: "ArrowDown", code: 'ArrowDown', keyCode: KeyboardEvent.DOM_VK_DOWN, bubbles: true, }
|
||
));
|
||
await Zotero.Promise.delay(50);
|
||
pubTitleField.ref.dispatchEvent(new KeyboardEvent(
|
||
'keydown', { key: "Enter", code: "Enter", keyCode: KeyboardEvent.DOM_VK_RETURN, bubbles: true }
|
||
));
|
||
await modifyPromise;
|
||
|
||
assert.equal(item1.getField('publicationTitle'), sharedTitle);
|
||
assert.equal(item2.getField('publicationTitle'), sharedTitle);
|
||
assert.equal(item3.getField('publicationTitle'), sharedTitle);
|
||
assert.equal(item4.getField('publicationTitle'), sharedTitle);
|
||
});
|
||
|
||
it("should not show View Online button for URL and DOI fields in batch edit mode", async function () {
|
||
let item1 = await _createDataObject('item', { itemType: 'journalArticle' });
|
||
item1.setField('url', 'https://example.com/1');
|
||
item1.setField('DOI', '10.1234/test1');
|
||
await item1.saveTx();
|
||
|
||
let item2 = await _createDataObject('item', { itemType: 'journalArticle' });
|
||
item2.setField('url', 'https://example.com/2');
|
||
item2.setField('DOI', '10.1234/test2');
|
||
await item2.saveTx();
|
||
|
||
await ZoteroPane.selectItems([item1.id, item2.id]);
|
||
|
||
let itemPane = win.ZoteroPane.itemPane;
|
||
let itemDetails = ZoteroPane.itemPane._itemDetails;
|
||
|
||
let batchEditEnableBtn = doc.getElementById('batch-edit-prompt-enable');
|
||
batchEditEnableBtn.click();
|
||
await itemDetails._renderPromise;
|
||
|
||
let itemBox = itemPane.querySelector('#zotero-editpane-info-box');
|
||
|
||
let urlLink = itemBox.querySelector('#itembox-field-url-link');
|
||
let doiLink = itemBox.querySelector('#itembox-field-DOI-link');
|
||
|
||
// View Online buttons should be hidden in batch edit mode
|
||
assert.ok(urlLink.hidden, "URL View Online button should be hidden in batch edit mode");
|
||
assert.ok(doiLink.hidden, "DOI View Online button should be hidden in batch edit mode");
|
||
});
|
||
|
||
it("should not show date field status or tooltip in batch edit mode", async function () {
|
||
let item1 = await _createDataObject('item', { itemType: 'journalArticle' });
|
||
item1.setField('date', '2024-01-15');
|
||
await item1.saveTx();
|
||
|
||
let item2 = await _createDataObject('item', { itemType: 'journalArticle' });
|
||
item2.setField('date', '2023-06-20');
|
||
await item2.saveTx();
|
||
|
||
await ZoteroPane.selectItems([item1.id, item2.id]);
|
||
|
||
let itemPane = win.ZoteroPane.itemPane;
|
||
let itemDetails = ZoteroPane.itemPane._itemDetails;
|
||
|
||
let batchEditEnableBtn = doc.getElementById('batch-edit-prompt-enable');
|
||
batchEditEnableBtn.click();
|
||
await itemDetails._renderPromise;
|
||
|
||
let itemBox = itemPane.querySelector('#zotero-editpane-info-box');
|
||
|
||
// Date field status indicator (y m d) should not be present
|
||
let dateStatus = itemBox.querySelector('#zotero-date-field-status');
|
||
assert.isNull(dateStatus, "date field status should not be present in batch edit mode");
|
||
|
||
// Date field should not have a tooltip
|
||
let dateField = itemBox.querySelector('editable-text[fieldname="date"]');
|
||
assert.ok(dateField, "date field should exist");
|
||
assert.isNull(dateField.getAttribute('tooltiptext'), "date field should not have a tooltip in batch edit mode");
|
||
});
|
||
|
||
it("should focus a read-only multiple-values field via its label, keeping the Multiple placeholder", async function () {
|
||
let multiplePlaceholder = Zotero.getString('item-pane-batch-editing-multiple-values-placeholder');
|
||
|
||
// Differing dateAdded values make the read-only dateAdded field show the "Multiple" placeholder
|
||
let item1 = await _createDataObject('item', { itemType: 'journalArticle', dateAdded: '2020-01-01 00:00:00' });
|
||
let item2 = await _createDataObject('item', { itemType: 'journalArticle', dateAdded: '2021-06-15 00:00:00' });
|
||
|
||
await ZoteroPane.selectItems([item1.id, item2.id]);
|
||
await waitForFrame();
|
||
|
||
let itemPane = win.ZoteroPane.itemPane;
|
||
let itemDetails = ZoteroPane.itemPane._itemDetails;
|
||
|
||
let batchEditEnableBtn = doc.getElementById('batch-edit-prompt-enable');
|
||
batchEditEnableBtn.click();
|
||
await itemDetails._renderPromise;
|
||
|
||
await activateZoteroPane();
|
||
|
||
let itemBox = itemPane.querySelector('#zotero-editpane-info-box');
|
||
|
||
let dateAddedField = itemBox.querySelector('editable-text[fieldname="dateAdded"]');
|
||
assert.ok(dateAddedField, "dateAdded field should exist");
|
||
assert.isTrue(dateAddedField.readOnly, "dateAdded field should be read-only");
|
||
assert.isTrue(dateAddedField.multipleValues, "dateAdded should show multiple values");
|
||
assert.equal(dateAddedField.placeholder, multiplePlaceholder);
|
||
assert.isFalse(dateAddedField.focused, "dateAdded field should not start focused");
|
||
|
||
// Read-only multiple-values fields are focusable for keyboard access.
|
||
// Clicking the label focuses the value, which should show an empty input
|
||
// with the "Multiple" placeholder
|
||
let dateAddedLabel = itemBox.querySelector('#itembox-field-dateAdded-label');
|
||
assert.ok(dateAddedLabel, "dateAdded label should exist");
|
||
dateAddedLabel.click();
|
||
await waitForFrame();
|
||
|
||
assert.isTrue(dateAddedField.focused, "clicking a read-only field's label should focus it");
|
||
// The field stays read-only, so it can't actually be edited
|
||
assert.isTrue(dateAddedField.readOnly, "field should remain read-only when focused");
|
||
assert.equal(dateAddedField.ref.value, '', "focused read-only field should show no value");
|
||
assert.equal(
|
||
dateAddedField.ref.placeholder,
|
||
multiplePlaceholder,
|
||
"the 'Multiple' placeholder should remain while focused"
|
||
);
|
||
});
|
||
|
||
it("should not show 'Multiple' for a read-only date field when all items share the same value", async function () {
|
||
let multiplePlaceholder = Zotero.getString('item-pane-batch-editing-multiple-values-placeholder');
|
||
|
||
// Both items share an identical dateAdded, so the read-only dateAdded
|
||
// field should display that single value rather than the "Multiple"
|
||
// placeholder
|
||
let sharedDateAdded = '2020-01-01 12:00:00';
|
||
let item1 = await _createDataObject('item', { itemType: 'journalArticle', dateAdded: sharedDateAdded });
|
||
let item2 = await _createDataObject('item', { itemType: 'journalArticle', dateAdded: sharedDateAdded });
|
||
|
||
await ZoteroPane.selectItems([item1.id, item2.id]);
|
||
await waitForFrame();
|
||
|
||
let itemPane = win.ZoteroPane.itemPane;
|
||
let itemDetails = ZoteroPane.itemPane._itemDetails;
|
||
|
||
let batchEditEnableBtn = doc.getElementById('batch-edit-prompt-enable');
|
||
batchEditEnableBtn.click();
|
||
await itemDetails._renderPromise;
|
||
|
||
let itemBox = itemPane.querySelector('#zotero-editpane-info-box');
|
||
|
||
let dateAddedField = itemBox.querySelector('editable-text[fieldname="dateAdded"]');
|
||
assert.ok(dateAddedField, "dateAdded field should exist");
|
||
assert.isTrue(dateAddedField.readOnly, "dateAdded field should be read-only");
|
||
|
||
// Underlying values are identical, so the field must not be in
|
||
// multiple-values mode
|
||
assert.isFalse(
|
||
dateAddedField.multipleValues,
|
||
"dateAdded should not be in multiple-values mode when all items share the same value"
|
||
);
|
||
assert.notEqual(
|
||
dateAddedField.placeholder,
|
||
multiplePlaceholder,
|
||
"the 'Multiple' placeholder should not be shown when the value is identical"
|
||
);
|
||
// The shared value should be displayed
|
||
assert.equal(
|
||
dateAddedField.value,
|
||
itemBox.dateTimeFromUTC(item1.getField('dateAdded')),
|
||
"the shared date value should be displayed"
|
||
);
|
||
});
|
||
|
||
it("should show union of fields from all item types in cross-type batch edit", async function () {
|
||
let item1 = await _createDataObject('item', { itemType: 'book' });
|
||
item1.setField('publisher', 'Test Publisher');
|
||
await item1.saveTx();
|
||
|
||
let item2 = await _createDataObject('item', { itemType: 'audioRecording' });
|
||
item2.setField('label', 'Test Label');
|
||
await item2.saveTx();
|
||
|
||
await ZoteroPane.selectItems([item1.id, item2.id]);
|
||
|
||
let itemPane = win.ZoteroPane.itemPane;
|
||
let itemDetails = ZoteroPane.itemPane._itemDetails;
|
||
|
||
let batchEditEnableBtn = doc.getElementById('batch-edit-prompt-enable');
|
||
batchEditEnableBtn.click();
|
||
await itemDetails._renderPromise;
|
||
|
||
let itemBox = itemPane.querySelector('#zotero-editpane-info-box');
|
||
|
||
// Shared base field 'publisher' should appear (book: publisher, audioRecording: label)
|
||
let publisherField = itemBox.querySelector('editable-text[fieldname="publisher"]');
|
||
assert.ok(publisherField, "publisher (base field) should appear");
|
||
|
||
// Shared base field 'medium' should appear (book: format, audioRecording: audioRecordingFormat)
|
||
let mediumField = itemBox.querySelector('editable-text[fieldname="medium"]');
|
||
assert.ok(mediumField, "medium (base field) should appear");
|
||
|
||
// Book-only fields should appear
|
||
let editionField = itemBox.querySelector('editable-text[fieldname="edition"]');
|
||
assert.ok(editionField, "edition (book-only) should appear");
|
||
|
||
// audioRecording-only fields should appear
|
||
let runningTimeField = itemBox.querySelector('editable-text[fieldname="runningTime"]');
|
||
assert.ok(runningTimeField, "runningTime (audioRecording-only) should appear");
|
||
});
|
||
it("should use base field label when field is shared across types, type-specific label otherwise", async function () {
|
||
let item1 = await _createDataObject('item', { itemType: 'book' });
|
||
await item1.saveTx();
|
||
|
||
let item2 = await _createDataObject('item', { itemType: 'audioRecording' });
|
||
await item2.saveTx();
|
||
|
||
await ZoteroPane.selectItems([item1.id, item2.id]);
|
||
|
||
let itemPane = win.ZoteroPane.itemPane;
|
||
let itemDetails = ZoteroPane.itemPane._itemDetails;
|
||
|
||
let batchEditEnableBtn = doc.getElementById('batch-edit-prompt-enable');
|
||
batchEditEnableBtn.click();
|
||
await itemDetails._renderPromise;
|
||
|
||
let itemBox = itemPane.querySelector('#zotero-editpane-info-box');
|
||
|
||
// Shared field 'publisher' should use base field label
|
||
let publisherLabel = itemBox.querySelector('#itembox-field-publisher-label');
|
||
assert.ok(publisherLabel, "publisher label should exist");
|
||
assert.equal(publisherLabel.textContent, Zotero.ItemFields.getLocalizedString('publisher'));
|
||
|
||
// audioRecording-only field 'runningTime' should use type-specific label
|
||
let runningTimeLabel = itemBox.querySelector('#itembox-field-runningTime-label');
|
||
assert.ok(runningTimeLabel, "runningTime label should exist");
|
||
assert.equal(runningTimeLabel.textContent, Zotero.ItemFields.getLocalizedString('runningTime'));
|
||
});
|
||
it("should show 'Multiple' placeholder for shared base-mapped fields with different values across types", async function () {
|
||
let item1 = await _createDataObject('item', { itemType: 'book' });
|
||
item1.setField('publisher', 'Book Publisher');
|
||
await item1.saveTx();
|
||
|
||
let item2 = await _createDataObject('item', { itemType: 'audioRecording' });
|
||
item2.setField('label', 'Audio Label');
|
||
await item2.saveTx();
|
||
|
||
await ZoteroPane.selectItems([item1.id, item2.id]);
|
||
|
||
let itemPane = win.ZoteroPane.itemPane;
|
||
let itemDetails = ZoteroPane.itemPane._itemDetails;
|
||
|
||
let batchEditEnableBtn = doc.getElementById('batch-edit-prompt-enable');
|
||
batchEditEnableBtn.click();
|
||
await itemDetails._renderPromise;
|
||
|
||
let itemBox = itemPane.querySelector('#zotero-editpane-info-box');
|
||
let publisherField = itemBox.querySelector('editable-text[fieldname="publisher"]');
|
||
assert.ok(publisherField, "publisher field should exist");
|
||
assert.isTrue(publisherField.multipleValues, "publisher should show multiple values");
|
||
assert.equal(
|
||
publisherField.placeholder,
|
||
Zotero.getString('item-pane-batch-editing-multiple-values-placeholder')
|
||
);
|
||
});
|
||
it("should apply value to base-mapped fields across different item types", async function () {
|
||
let item1 = await _createDataObject('item', { itemType: 'book' });
|
||
item1.setField('publisher', 'Old Publisher');
|
||
await item1.saveTx();
|
||
|
||
let item2 = await _createDataObject('item', { itemType: 'audioRecording' });
|
||
item2.setField('label', 'Old Label');
|
||
await item2.saveTx();
|
||
|
||
await ZoteroPane.selectItems([item1.id, item2.id]);
|
||
|
||
let itemPane = win.ZoteroPane.itemPane;
|
||
let itemDetails = ZoteroPane.itemPane._itemDetails;
|
||
|
||
let batchEditEnableBtn = doc.getElementById('batch-edit-prompt-enable');
|
||
batchEditEnableBtn.click();
|
||
await itemDetails._renderPromise;
|
||
|
||
let itemBox = itemPane.querySelector('#zotero-editpane-info-box');
|
||
let publisherField = itemBox.querySelector('editable-text[fieldname="publisher"]');
|
||
|
||
// Simulate editing the field
|
||
publisherField._ignoredWindowInactiveBlur = false;
|
||
await activateZoteroPane();
|
||
await Zotero.Promise.delay(50);
|
||
publisherField.focus();
|
||
await Zotero.Promise.delay(50);
|
||
|
||
// Type a new value
|
||
let modifyPromise = waitForItemEvent('modify');
|
||
publisherField.value = 'New Shared Publisher';
|
||
publisherField.blur();
|
||
await modifyPromise;
|
||
|
||
// Both items should have the new value in their type-specific fields
|
||
assert.equal(item1.getField('publisher'), 'New Shared Publisher', "book publisher should be updated");
|
||
assert.equal(item2.getField('label'), 'New Shared Publisher', "audioRecording label should be updated");
|
||
});
|
||
it("should skip items when setting a type-specific field that doesn't apply to all items", async function () {
|
||
let item1 = await _createDataObject('item', { itemType: 'audioRecording' });
|
||
item1.setField('runningTime', '3:45');
|
||
await item1.saveTx();
|
||
|
||
let item2 = await _createDataObject('item', { itemType: 'book' });
|
||
await item2.saveTx();
|
||
|
||
await ZoteroPane.selectItems([item1.id, item2.id]);
|
||
|
||
let itemPane = win.ZoteroPane.itemPane;
|
||
let itemDetails = ZoteroPane.itemPane._itemDetails;
|
||
|
||
let batchEditEnableBtn = doc.getElementById('batch-edit-prompt-enable');
|
||
batchEditEnableBtn.click();
|
||
await itemDetails._renderPromise;
|
||
|
||
let itemBox = itemPane.querySelector('#zotero-editpane-info-box');
|
||
let runningTimeField = itemBox.querySelector('editable-text[fieldname="runningTime"]');
|
||
assert.ok(runningTimeField, "runningTime field should exist");
|
||
|
||
// Simulate editing the field
|
||
runningTimeField._ignoredWindowInactiveBlur = false;
|
||
await activateZoteroPane();
|
||
await Zotero.Promise.delay(50);
|
||
runningTimeField.focus();
|
||
await Zotero.Promise.delay(50);
|
||
|
||
// Type a new value -- should not throw for book
|
||
let modifyPromise = waitForItemEvent('modify');
|
||
runningTimeField.value = '5:00';
|
||
runningTimeField.blur();
|
||
await modifyPromise;
|
||
|
||
assert.equal(item1.getField('runningTime'), '5:00', "audioRecording runningTime should be updated");
|
||
// Book should be unaffected -- no error thrown
|
||
});
|
||
it("should show 'Multiple' for Added By in group library batch edit with different users", async function () {
|
||
let group = await createGroup();
|
||
await Zotero.Users.setName(1, 'User One');
|
||
await Zotero.Users.setName(2, 'User Two');
|
||
|
||
let item1 = createUnsavedDataObject('item', { libraryID: group.libraryID });
|
||
item1.setField('createdByUserID', 1);
|
||
await item1.saveTx();
|
||
|
||
let item2 = createUnsavedDataObject('item', { libraryID: group.libraryID });
|
||
item2.setField('createdByUserID', 2);
|
||
await item2.saveTx();
|
||
|
||
await ZoteroPane.selectItems([item1.id, item2.id]);
|
||
|
||
let itemPane = win.ZoteroPane.itemPane;
|
||
let itemDetails = ZoteroPane.itemPane._itemDetails;
|
||
|
||
let batchEditEnableBtn = doc.getElementById('batch-edit-prompt-enable');
|
||
batchEditEnableBtn.click();
|
||
await itemDetails._renderPromise;
|
||
|
||
let itemBox = itemPane.querySelector('#zotero-editpane-info-box');
|
||
|
||
// "Added By" field should show "Multiple..." placeholder
|
||
let addedByRow = itemBox.querySelector('.meta-label[fieldname="addedBy"]');
|
||
assert.ok(addedByRow, "addedBy row should exist");
|
||
let addedByValue = addedByRow.parentElement.querySelector('editable-text');
|
||
assert.isTrue(addedByValue.multipleValues, "addedBy should have multipleValues");
|
||
assert.equal(
|
||
addedByValue.placeholder,
|
||
Zotero.getString('item-pane-batch-editing-multiple-values-placeholder'),
|
||
"addedBy should show Multiple placeholder"
|
||
);
|
||
assert.equal(addedByValue.value, '', "addedBy value should be empty");
|
||
assert.notEqual(addedByValue.ref.tabIndex, -1, "addedBy should be focusable");
|
||
|
||
// Group erasure cascades to items
|
||
await group.eraseTx();
|
||
});
|
||
it("should show user name for Added By in group library batch edit when all items have the same user", async function () {
|
||
let group = await createGroup();
|
||
await Zotero.Users.setName(1, 'Same User');
|
||
|
||
let item1 = createUnsavedDataObject('item', { libraryID: group.libraryID });
|
||
item1.setField('createdByUserID', 1);
|
||
await item1.saveTx();
|
||
|
||
let item2 = createUnsavedDataObject('item', { libraryID: group.libraryID });
|
||
item2.setField('createdByUserID', 1);
|
||
await item2.saveTx();
|
||
|
||
await ZoteroPane.selectItems([item1.id, item2.id]);
|
||
|
||
let itemPane = win.ZoteroPane.itemPane;
|
||
let itemDetails = ZoteroPane.itemPane._itemDetails;
|
||
|
||
let batchEditEnableBtn = doc.getElementById('batch-edit-prompt-enable');
|
||
batchEditEnableBtn.click();
|
||
await itemDetails._renderPromise;
|
||
|
||
let itemBox = itemPane.querySelector('#zotero-editpane-info-box');
|
||
|
||
// "Added By" field should show the user name
|
||
let addedByRow = itemBox.querySelector('.meta-label[fieldname="addedBy"]');
|
||
assert.ok(addedByRow, "addedBy row should exist");
|
||
let addedByValue = addedByRow.parentElement.querySelector('editable-text');
|
||
assert.equal(addedByValue.value, 'Same User', "addedBy should show user name");
|
||
// With a single shared value there is no "Multiple" placeholder, so the read-only
|
||
// field should stay reachable by keyboard users, same as in non-batch mode
|
||
assert.isFalse(addedByValue.multipleValues, "addedBy should not be in multiple-values mode");
|
||
assert.equal(addedByValue.placeholder, '', "addedBy should not show the Multiple placeholder");
|
||
assert.notEqual(addedByValue.ref.tabIndex, -1, "addedBy should remain focusable");
|
||
|
||
await group.eraseTx();
|
||
});
|
||
it("should keep focus on a read-only user field across a re-render in batch edit mode", async function () {
|
||
let group = await createGroup();
|
||
await Zotero.Users.setName(1, 'Same User');
|
||
|
||
let item1 = createUnsavedDataObject('item', { libraryID: group.libraryID });
|
||
item1.setField('createdByUserID', 1);
|
||
await item1.saveTx();
|
||
|
||
let item2 = createUnsavedDataObject('item', { libraryID: group.libraryID });
|
||
item2.setField('createdByUserID', 1);
|
||
await item2.saveTx();
|
||
|
||
await ZoteroPane.selectItems([item1.id, item2.id]);
|
||
|
||
let itemPane = win.ZoteroPane.itemPane;
|
||
let itemDetails = ZoteroPane.itemPane._itemDetails;
|
||
|
||
let batchEditEnableBtn = doc.getElementById('batch-edit-prompt-enable');
|
||
batchEditEnableBtn.click();
|
||
await itemDetails._renderPromise;
|
||
|
||
await activateZoteroPane();
|
||
|
||
let itemBox = itemPane.querySelector('#zotero-editpane-info-box');
|
||
|
||
// Focus the read-only "added by" field (single shared user, so it is focusable)
|
||
let addedByValue = itemBox.querySelector('.meta-label[fieldname="addedBy"]')
|
||
?.parentElement.querySelector('editable-text');
|
||
assert.ok(addedByValue, "addedBy field should exist");
|
||
addedByValue.focus();
|
||
assert.isTrue(addedByValue.focused, "addedBy field should be focused");
|
||
|
||
// A re-render (which batch edits trigger on every save) must not drop focus --
|
||
// the field has to be identifiable by _saveFieldFocus/_restoreFieldFocus
|
||
await itemBox._forceRenderAll();
|
||
|
||
let newAddedByValue = itemBox.querySelector('.meta-label[fieldname="addedBy"]')
|
||
?.parentElement.querySelector('editable-text');
|
||
assert.ok(newAddedByValue, "addedBy field should still exist after re-render");
|
||
assert.isTrue(
|
||
newAddedByValue.focused,
|
||
"focus should be restored to the addedBy field after a re-render"
|
||
);
|
||
|
||
await group.eraseTx();
|
||
});
|
||
it("should undo and redo a batch field edit", async function () {
|
||
let item1 = await createDataObject('item', { itemType: 'journalArticle' });
|
||
item1.setField('publicationTitle', 'Journal Alpha');
|
||
await item1.saveTx();
|
||
|
||
let item2 = await createDataObject('item', { itemType: 'journalArticle' });
|
||
item2.setField('publicationTitle', 'Journal Beta');
|
||
await item2.saveTx();
|
||
|
||
let item3 = await createDataObject('item', { itemType: 'journalArticle' });
|
||
item3.setField('publicationTitle', 'Journal Gamma');
|
||
await item3.saveTx();
|
||
|
||
await ZoteroPane.selectItems([item1.id, item2.id, item3.id]);
|
||
Zotero.UndoHistory.clear();
|
||
|
||
let itemPane = win.ZoteroPane.itemPane;
|
||
let itemDetails = ZoteroPane.itemPane._itemDetails;
|
||
|
||
let batchEditEnableBtn = doc.getElementById('batch-edit-prompt-enable');
|
||
batchEditEnableBtn.click();
|
||
await itemDetails._renderPromise;
|
||
|
||
let itemBox = itemPane.querySelector('#zotero-editpane-info-box');
|
||
let pubTitleField = itemBox.querySelector('editable-text[fieldname="publicationTitle"]');
|
||
assert.ok(pubTitleField, "publicationTitle field should exist");
|
||
|
||
assert.equal(pubTitleField.value, '', "field value should be empty before edit");
|
||
assert.equal(pubTitleField.placeholder, Zotero.getString('item-pane-batch-editing-multiple-values-placeholder'), "field should show Multiple placeholder before edit");
|
||
|
||
pubTitleField._ignoredWindowInactiveBlur = false;
|
||
await activateZoteroPane();
|
||
await Zotero.Promise.delay(50);
|
||
pubTitleField.focus();
|
||
|
||
// Options sorted alphabetically: Alpha, Beta, Gamma + "no value" option
|
||
await waitForCallback(() => pubTitleField.ref.mController.matchCount === 4, 100, 500);
|
||
// Select "Journal Alpha" from autocomplete (first entry)
|
||
let modifyPromise = waitForItemEvent('modify');
|
||
pubTitleField.ref.dispatchEvent(new KeyboardEvent(
|
||
'keydown', { key: "ArrowDown", code: 'ArrowDown', keyCode: KeyboardEvent.DOM_VK_DOWN, bubbles: true }
|
||
));
|
||
await Zotero.Promise.delay(50);
|
||
pubTitleField.ref.dispatchEvent(new KeyboardEvent(
|
||
'keydown', { key: "Enter", code: "Enter", keyCode: KeyboardEvent.DOM_VK_RETURN, bubbles: true }
|
||
));
|
||
await modifyPromise;
|
||
// waitForItemEvent resolves during Notifier.commit, but UndoHistory's
|
||
// commit callback runs after -- wait a tick for it to complete.
|
||
await Zotero.Promise.delay(0);
|
||
|
||
assert.equal(item1.getField('publicationTitle'), 'Journal Alpha');
|
||
assert.equal(item2.getField('publicationTitle'), 'Journal Alpha');
|
||
assert.equal(item3.getField('publicationTitle'), 'Journal Alpha');
|
||
assert.isTrue(Zotero.UndoHistory.canUndo(), "should be able to undo");
|
||
|
||
// Undo should revert all items
|
||
await Zotero.UndoHistory.undo();
|
||
assert.equal(item1.getField('publicationTitle'), 'Journal Alpha',
|
||
"item1 should be unchanged (already had the selected value)");
|
||
assert.equal(item2.getField('publicationTitle'), 'Journal Beta',
|
||
"item2 should revert to original");
|
||
assert.equal(item3.getField('publicationTitle'), 'Journal Gamma',
|
||
"item3 should revert to original");
|
||
|
||
// Re-query since render() rebuilds the DOM
|
||
pubTitleField = itemBox.querySelector('editable-text[fieldname="publicationTitle"]');
|
||
assert.equal(pubTitleField.value, '', "field value should be empty after undo");
|
||
assert.equal(pubTitleField.placeholder, Zotero.getString('item-pane-batch-editing-multiple-values-placeholder'), "field should show Multiple placeholder after undo");
|
||
|
||
// Redo should re-apply to all items
|
||
await Zotero.UndoHistory.redo();
|
||
assert.equal(item1.getField('publicationTitle'), 'Journal Alpha');
|
||
assert.equal(item2.getField('publicationTitle'), 'Journal Alpha');
|
||
assert.equal(item3.getField('publicationTitle'), 'Journal Alpha');
|
||
});
|
||
it("should transform title case for all items in batch edit mode", async function () {
|
||
let titleCaseTitle = "The Great Gatsby";
|
||
let sentenceCaseTitle = "to kill a mockingbird";
|
||
|
||
// Create two books with different titles in different cases
|
||
let item1 = await _createDataObject('item', { itemType: 'book' });
|
||
item1.setField('title', titleCaseTitle);
|
||
await item1.saveTx();
|
||
|
||
let item2 = await _createDataObject('item', { itemType: 'book' });
|
||
item2.setField('title', sentenceCaseTitle);
|
||
await item2.saveTx();
|
||
|
||
await ZoteroPane.selectItems([item1.id, item2.id]);
|
||
|
||
let itemPane = win.ZoteroPane.itemPane;
|
||
let itemDetails = ZoteroPane.itemPane._itemDetails;
|
||
|
||
let batchEditEnableBtn = doc.getElementById('batch-edit-prompt-enable');
|
||
batchEditEnableBtn.click();
|
||
await itemDetails._renderPromise;
|
||
|
||
// Find the title field
|
||
let itemBox = itemPane.querySelector('#zotero-editpane-info-box');
|
||
let titleField = itemBox.querySelector('editable-text[fieldname="title"]');
|
||
assert.ok(titleField, "title field should exist");
|
||
|
||
// Find and click the options button for the title field
|
||
let optionsButton = itemBox.querySelector('#itembox-field-title-options');
|
||
assert.ok(optionsButton, "options button should exist");
|
||
|
||
// Click the options button to open the context menu
|
||
let menuPromise = new Promise((resolve) => {
|
||
let observer = new MutationObserver((mutations) => {
|
||
for (let mutation of mutations) {
|
||
for (let node of mutation.addedNodes) {
|
||
if (node.tagName === 'menupopup') {
|
||
observer.disconnect();
|
||
resolve(node);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
observer.observe(itemBox.querySelector('#info-box > popupset'), { childList: true });
|
||
});
|
||
|
||
optionsButton.click();
|
||
let menupopup = await menuPromise;
|
||
|
||
let titleCaseMenuItem = Array.from(menupopup.querySelectorAll('menuitem'))
|
||
.find(item => item.getAttribute('label') === Zotero.getString('zotero.item.textTransform.titlecase'));
|
||
assert.ok(titleCaseMenuItem, "title case menu item should exist");
|
||
|
||
let modifyPromise = waitForItemEvent('modify');
|
||
titleCaseMenuItem.click();
|
||
// Label should not flash individual values -- it stays as "Multiple" in batch mode
|
||
assert.equal(titleField.value, '', "title field value should remain empty in batch mode");
|
||
assert.equal(titleField.placeholder, Zotero.getString('item-pane-batch-editing-multiple-values-placeholder'), "title field should still show Multiple placeholder");
|
||
await modifyPromise;
|
||
|
||
assert.equal(item1.getField('title'), "The Great Gatsby", "item1 should remain in title case");
|
||
assert.equal(item2.getField('title'), "To Kill a Mockingbird", "item2 should be transformed to title case");
|
||
});
|
||
|
||
it("should show options button and transform case when primary item field is empty", async function () {
|
||
// Item 1 has no seriesTitle, items 2 and 3 do
|
||
let item1 = await createDataObject('item', { itemType: 'journalArticle' });
|
||
await item1.saveTx();
|
||
|
||
let item2 = await createDataObject('item', { itemType: 'journalArticle' });
|
||
item2.setField('seriesTitle', 'advances in neural information processing');
|
||
await item2.saveTx();
|
||
|
||
let item3 = await createDataObject('item', { itemType: 'journalArticle' });
|
||
item3.setField('seriesTitle', 'proceedings of the ACM conference');
|
||
await item3.saveTx();
|
||
|
||
await ZoteroPane.selectItems([item1.id, item2.id, item3.id]);
|
||
|
||
let itemPane = win.ZoteroPane.itemPane;
|
||
let itemDetails = ZoteroPane.itemPane._itemDetails;
|
||
|
||
let batchEditEnableBtn = doc.getElementById('batch-edit-prompt-enable');
|
||
batchEditEnableBtn.click();
|
||
await itemDetails._renderPromise;
|
||
|
||
let itemBox = itemPane.querySelector('#zotero-editpane-info-box');
|
||
let optionsButton = itemBox.querySelector('#itembox-field-seriesTitle-options');
|
||
assert.ok(optionsButton, "options button should exist for seriesTitle");
|
||
assert.isFalse(optionsButton.hidden, "options button should be visible when extra items have values");
|
||
|
||
// Open the context menu via the options button
|
||
let menuPromise = new Promise((resolve) => {
|
||
let observer = new MutationObserver((mutations) => {
|
||
for (let mutation of mutations) {
|
||
for (let node of mutation.addedNodes) {
|
||
if (node.tagName === 'menupopup') {
|
||
observer.disconnect();
|
||
resolve(node);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
observer.observe(itemBox.querySelector('#info-box > popupset'), { childList: true });
|
||
});
|
||
|
||
optionsButton.click();
|
||
let menupopup = await menuPromise;
|
||
|
||
let titleCaseMenuItem = Array.from(menupopup.querySelectorAll('menuitem'))
|
||
.find(mi => mi.getAttribute('label') === Zotero.getString('zotero.item.textTransform.titlecase'));
|
||
assert.ok(titleCaseMenuItem, "title case menu item should exist");
|
||
|
||
let modifyPromise = waitForItemEvent('modify');
|
||
titleCaseMenuItem.click();
|
||
await modifyPromise;
|
||
|
||
assert.equal(item1.getField('seriesTitle'), '', "item1 should remain empty");
|
||
assert.equal(item2.getField('seriesTitle'), 'Advances in Neural Information Processing',
|
||
"item2 should be transformed to title case");
|
||
assert.equal(item3.getField('seriesTitle'), 'Proceedings of the ACM Conference',
|
||
"item3 should be transformed to title case");
|
||
});
|
||
});
|
||
|
||
it("should focus read-only fields with multiple values, showing the Multiple placeholder", async function () {
|
||
let item1 = await createDataObject('item', { itemType: 'journalArticle', dateAdded: '2020-01-01 00:00:00' });
|
||
let item2 = await createDataObject('item', { itemType: 'journalArticle', dateAdded: '2021-06-15 00:00:00' });
|
||
|
||
await ZoteroPane.selectItems([item1.id, item2.id]);
|
||
|
||
let itemPane = win.ZoteroPane.itemPane;
|
||
let itemDetails = ZoteroPane.itemPane._itemDetails;
|
||
|
||
let batchEditEnableBtn = doc.getElementById('batch-edit-prompt-enable');
|
||
batchEditEnableBtn.click();
|
||
await itemDetails._renderPromise;
|
||
|
||
await activateZoteroPane();
|
||
|
||
let itemBox = itemPane.querySelector('#zotero-editpane-info-box');
|
||
let dateAddedField = itemBox.querySelector('editable-text[fieldname="dateAdded"]');
|
||
assert.ok(dateAddedField, "dateAdded field should exist");
|
||
assert.isTrue(dateAddedField.readOnly, "dateAdded should be read-only");
|
||
assert.isTrue(dateAddedField.multipleValues, "dateAdded should have multiple values");
|
||
assert.equal(
|
||
dateAddedField.placeholder,
|
||
Zotero.getString('item-pane-batch-editing-multiple-values-placeholder')
|
||
);
|
||
|
||
assert.notEqual(dateAddedField.ref.tabIndex, -1, "tabIndex should not be -1");
|
||
|
||
dateAddedField.focus();
|
||
await waitForFrame();
|
||
|
||
assert.isTrue(dateAddedField.focused, "read-only multiple-values field should be focusable");
|
||
assert.isTrue(dateAddedField.readOnly, "field should remain read-only when focused");
|
||
assert.equal(dateAddedField.ref.value, '', "focused read-only field should show no value");
|
||
assert.equal(
|
||
dateAddedField.ref.placeholder,
|
||
Zotero.getString('item-pane-batch-editing-multiple-values-placeholder'),
|
||
"Multiple placeholder should remain while focused"
|
||
);
|
||
await item2.eraseTx();
|
||
await item1.eraseTx();
|
||
});
|
||
});
|