describe("Item pane", function () { var win, doc, ZoteroPane, Zotero_Tabs, ZoteroContextPane, itemsView; async function waitForPreviewBoxRender(box) { let res = await waitForCallback( () => box._asyncRenderItemID && !box._asyncRendering, 100, 3); 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, 3); if (res instanceof Error) { throw res; } await preview._reader._initPromise; 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 () { 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); }); }); describe("Info pane", function () { 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", function* () { var item = new Zotero.Item('book'); var id = yield item.saveTx(); var itemBox = doc.getElementById('zotero-editpane-item-box'); var label = itemBox.querySelectorAll('[fieldname="series"]')[1]; assert.equal(label.value, ''); item.setField('series', 'Test'); yield item.saveTx(); label = itemBox.querySelectorAll('[fieldname="series"]')[1]; assert.equal(label.value, 'Test'); yield 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-item-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 doc.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-item-box'); var label = itemBox.querySelector('#itembox-field-value-creator-0-lastName'); var firstlast = label.closest('.creator-type-value'); firstlast.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, 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-item-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-item-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-item-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-item-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' ); }); }); 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}`]); }); }); describe("Attachments pane", function () { let paneID = "attachments"; beforeEach(function () { Zotero.Prefs.set("panes.attachments.open", true); Zotero.Prefs.set("showAttachmentPreview", true); Zotero_Tabs.select("zotero-pane"); }); afterEach(function () { Zotero_Tabs.select("zotero-pane"); Zotero_Tabs.closeAll(); }); 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); assert.isTrue(await waitForPreviewBoxRender(attachmentsBox)); // 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 = ""; }); 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; 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 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; 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 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 = ""; }); 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); await waitForPreviewBoxReader(attachmentsBox, attachment1.id); assert.isTrue(await isPreviewDisplayed(attachmentsBox)); await ZoteroPane.selectItem(item2.id); await waitForPreviewBoxReader(attachmentsBox, attachment2.id); assert.isTrue(await isPreviewDisplayed(attachmentsBox)); itemDetails.pinnedPane = ""; }); 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); // Remove any existing preview to ensure the test is valid attachmentsBox._preview?.remove(); attachmentsBox._preview = null; let item = await createDataObject('item'); await importFileAttachment('test.pdf', { parentID: item.id }); await ZoteroPane.selectItem(item.id); assert.notExists(attachmentsBox._preview); assert.notExists(attachmentsBox.querySelector("#preview")); await waitForScrollToPane(itemDetails, paneID); await waitForPreviewBoxRender(attachmentsBox); assert.exists(await getBoxPreview(attachmentsBox)); 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); // Resize to very small height to ensure the attachment box is not in view let height = doc.documentElement.clientHeight; win.resizeTo(null, 100); const discardTimeout = 50; // Temporarily set discard timeout to 100ms for testing let currentDiscardTimeout = attachmentsBox._discardPreviewTimeout; attachmentsBox._discardPreviewTimeout = discardTimeout; let item = await createDataObject('item'); await importFileAttachment('test.pdf', { parentID: item.id }); await ZoteroPane.selectItem(item.id); await waitForScrollToPane(itemDetails, paneID); await waitForPreviewBoxRender(attachmentsBox); assert.isTrue(attachmentsBox._preview._isReaderInitialized); // Scroll the attachments pane out of view await waitForScrollToPane(itemDetails, 'info'); // Wait a bit for the preview to be discarded await Zotero.Promise.delay(discardTimeout + 100); assert.isFalse(attachmentsBox._preview._isReaderInitialized); win.resizeTo(null, height); attachmentsBox._discardPreviewTimeout = currentDiscardTimeout; }); }); describe("Notes pane", function () { it("should refresh on child note change", function* () { var item; var note1; var note2; yield 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 { yield Zotero.Promise.delay(1); } while (body.querySelectorAll('.row .label').length !== 2); // Update note text note2.setNote('C'); yield note2.saveTx(); // Wait for note list to update do { yield Zotero.Promise.delay(1); } while ([...body.querySelectorAll('.row .label')].every(label => label.textContent != 'C')); }); it("should refresh on child note trash", function* () { var item; var note1; var note2; yield 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 { yield Zotero.Promise.delay(1); } while (body.querySelectorAll('.row .label').length !== 2); // Click "-" in first note var promise = waitForDialog(); body.querySelector(".zotero-clicky-minus").click(); yield promise; // Wait for note list to update do { yield Zotero.Promise.delay(1); } while (body.querySelectorAll('.row .label').length !== 1); }); it("should refresh on child note delete", function* () { var item; var note1; var note2; yield 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 { yield Zotero.Promise.delay(1); } while (body.querySelectorAll('.row .label').length !== 2); yield note2.eraseTx(); // Wait for note list to update do { yield 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 () { 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; 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); 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("

TEST

"); 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 () { let itemDetails = ZoteroPane.itemPane._itemDetails; let attachmentBox = itemDetails.getPane(paneID); const discardTimeout = 50; // Temporarily set discard timeout to 100ms for testing let currentDiscardTimeout = attachmentBox._discardPreviewTimeout; attachmentBox._discardPreviewTimeout = discardTimeout; let item = await createDataObject('item'); let attachment = await importFileAttachment('test.pdf', { parentID: item.id }); await ZoteroPane.selectItem(attachment.id); await waitForScrollToPane(itemDetails, paneID); await waitForPreviewBoxRender(attachmentBox); assert.isTrue(attachmentBox._preview._isReaderInitialized); // Select a regular item to hide the attachment pane await ZoteroPane.selectItem(item.id); // Wait a bit for the preview to be discarded await Zotero.Promise.delay(discardTimeout + 100); assert.isFalse(attachmentBox._preview._isReaderInitialized); attachmentBox._discardPreviewTimeout = currentDiscardTimeout; }); }); describe("Note editor", function () { it("should refresh on note update", function* () { var item = new Zotero.Item('note'); var id = yield item.saveTx(); var noteEditor = doc.getElementById('zotero-note-editor'); // Wait for the editor yield new Zotero.Promise((resolve, reject) => { noteEditor.onInit(() => resolve()); }); assert.equal(noteEditor._editorInstance._iframeWindow.wrappedJSObject.getDataSync(), null); item.setNote('

Test

'); yield item.saveTx(); // Wait for asynchronous editor update do { yield Zotero.Promise.delay(10); } while ( !/

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", function* () { let feed = yield createFeed(); yield selectLibrary(win, feed.libraryID); yield waitForItemsLoad(win); var stub = sinon.stub(win.ZoteroPane, 'startItemReadTimeout'); var item = yield createDataObject('feedItem', { libraryID: feed.libraryID }); // Skip timed mark-as-read assert.ok(stub.called); stub.restore(); item.isRead = true; yield item.saveTx(); let button = ZoteroPane.itemPane.getCurrentPane().querySelector('.feed-item-toggleRead-button'); assert.equal(button.label, Zotero.getString('pane.item.markAsUnread')); yield 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 ); }); }); });