From dfc31d99610dc4178d12bbeb07c2795b3acd4aaa Mon Sep 17 00:00:00 2001 From: Bogdan Abaev Date: Fri, 16 May 2025 09:38:18 -0700 Subject: [PATCH] citation dlg: data structure refactor, bug fixes - replace objects with zoteroItem, citationItem, etc. as the main representation of an entry in the citation (CitationDataManager.items) with instances of a new BubbleItem class. BubbleItem has two static methods to create a new instance: BubbleItem.fromCitationItem() used to create entries from io.citation.citationItem on load, and BubbleItem.fromItem used to create entries from Zotero.Item when a new item is being added into the citation. BubbleItem.getCitationItem will return a citationItem in a format that citeproc consumes. This streamlines the flow of data, makes it easier to manage and avoids unnecessary conversions between citationItem and Zotero.Item. - This refactoring fixes the issue of added suffix/prefix sticking to an item and persisting even if the bubble is removed and re-added. Fixes: #5282 - add csl data to virtual item in io.customGetItem to remain consistent with Zotero.Integration.Citation.loadItemData. That way, csl data is always found on Zotero.Item and we don't have to check citationItems. Also: - apply deduplication to cited items, so that a cited item from the library that appears in selected or opened section does not have a duplicate entry in the cited section. - added initial test suite for citation dialog --- chrome/content/zotero/elements/bubbleInput.js | 13 +- .../zotero/integration/citationDialog.js | 282 ++++++----- .../integration/citationDialog/helpers.mjs | 26 +- .../citationDialog/popupHandler.mjs | 65 ++- .../citationDialog/searchHandler.mjs | 13 +- chrome/content/zotero/xpcom/editorInstance.js | 4 + test/tests/citationDialogTest.js | 440 +++++++++++++++++- 7 files changed, 654 insertions(+), 189 deletions(-) diff --git a/chrome/content/zotero/elements/bubbleInput.js b/chrome/content/zotero/elements/bubbleInput.js index 309562ab76..d6f21205f6 100644 --- a/chrome/content/zotero/elements/bubbleInput.js +++ b/chrome/content/zotero/elements/bubbleInput.js @@ -50,21 +50,20 @@ * are not present, remove bubbles whose citation items were removed, rearrange bubbles * if the items were moved, update bubble text if locator/prefix/suffix was changed. * Make sure that there is an input for user to type in before and after every bubble. - * @param {Object[]} combinedItems - array of objects { zoteroItem, citationItem, dialogReferenceID, selected }. - * zoteroItem - Zotero.Item, citationItem - object from io.citation.citationItems - * dialogReferenceID - String ID of this citation entry, selected - Boolean indicator if bubble should be highlighted + * @param {Object[]} bubblesConfig - array of objects { dialogReferenceID, bubbleString, selected} + * representing each bubble. */ - refresh(combinedItems) { + refresh(bubblesConfig) { // Remove bubbles of items that are no longer in the citations for (let bubble of this.getAllBubbles()) { let bubbleDialogReferenceID = bubble.getAttribute("dialogReferenceID"); - let itemExistsForBubble = combinedItems.find(({ dialogReferenceID }) => dialogReferenceID == bubbleDialogReferenceID); + let itemExistsForBubble = bubblesConfig.find(({ dialogReferenceID }) => dialogReferenceID == bubbleDialogReferenceID); if (!itemExistsForBubble) { bubble.remove(); } } // Ensure each item in the citation has a bubble in the right position - for (let [index, { dialogReferenceID, bubbleString }] of Object.entries(combinedItems)) { + for (let [index, { dialogReferenceID, bubbleString }] of Object.entries(bubblesConfig)) { let allBubbles = this.getAllBubbles(); let bubbleNode = allBubbles.find(candidate => candidate.getAttribute("dialogReferenceID") == dialogReferenceID); // Create bubble if it does not exist and append to the input @@ -95,7 +94,7 @@ // Highlight bubbles selected in the library view for (let bubble of this.getAllBubbles()) { let bubbleDialogReferenceID = bubble.getAttribute("dialogReferenceID"); - let itemObj = combinedItems.find(({ dialogReferenceID }) => dialogReferenceID == bubbleDialogReferenceID); + let itemObj = bubblesConfig.find(({ dialogReferenceID }) => dialogReferenceID == bubbleDialogReferenceID); if (itemObj) { bubble.classList.toggle("has-item-selected", !!itemObj.selected); } diff --git a/chrome/content/zotero/integration/citationDialog.js b/chrome/content/zotero/integration/citationDialog.js index 7cde71b937..864037ebf1 100644 --- a/chrome/content/zotero/integration/citationDialog.js +++ b/chrome/content/zotero/integration/citationDialog.js @@ -181,8 +181,8 @@ class Layout { let sections = []; // Tell SearchHandler which currently cited items are so they are not included in results - let citedItems = CitationDataManager.getCitationItems(); - let searchResultGroups = SearchHandler.getOrderedSearchResultGroups(citedItems); + let citedIDs = CitationDataManager.getCitedLibraryItemIDs(); + let searchResultGroups = SearchHandler.getOrderedSearchResultGroups(citedIDs); for (let { key, group, isLibrary } of searchResultGroups) { // selected items become a collapsible deck/list if there are multiple items let isGroupCollapsible = key == "selected" && group.length > 1; @@ -440,13 +440,8 @@ class LibraryLayout extends Layout { updateSelectedItems() { if (!libraryLayout.itemsView) return; let selectedItemIDs = new Set(libraryLayout.itemsView.getSelectedItems().map(item => item.id)); - for (let itemObj of CitationDataManager.items) { - if (selectedItemIDs.has(itemObj.zoteroItem.id)) { - itemObj.selected = true; - } - else { - itemObj.selected = false; - } + for (let bubbleItem of CitationDataManager.items) { + bubbleItem.selected = selectedItemIDs.has(bubbleItem.id); } IOManager.updateBubbleInput(); } @@ -711,10 +706,9 @@ class LibraryLayout extends Layout { if (event.key == "Backspace") { let itemsToRemove = this.itemsView.getSelectedItems(); for (let item of itemsToRemove) { - let citationItems = CitationDataManager.getItems({ zoteroItemID: item.id }); - for (let citationItem of citationItems) { - let { dialogReferenceID } = citationItem; - IOManager._deleteItem(dialogReferenceID); + let items = CitationDataManager.getItems({ itemID: item.id }); + for (let item of items) { + IOManager._deleteItem(item.dialogReferenceID); } } } @@ -722,12 +716,12 @@ class LibraryLayout extends Layout { // Highlight/de-highlight selected rows async _refreshItemsViewHighlightedRows() { - let selectedIDs = CitationDataManager.items.map(({ zoteroItem }) => zoteroItem.id).filter(id => !!id); + let selectedIDs = CitationDataManager.getCitedLibraryItemIDs(); // Wait for the tree to fully load to avoid a logged error that the tree is undefined while (!this.itemsView.tree) { await Zotero.Promise.delay(10); } - this.itemsView.setHighlightedRows(selectedIDs); + this.itemsView.setHighlightedRows([...selectedIDs]); } _scrollHorizontallyOnWheel(event) { @@ -820,13 +814,8 @@ class ListLayout extends Layout { updateSelectedItems() { let selectedIDs = new Set([...doc.querySelectorAll(".item.selected")].map(node => parseInt(node.getAttribute("itemID")))); - for (let itemObj of CitationDataManager.items) { - if (selectedIDs.has(itemObj.zoteroItem.id)) { - itemObj.selected = true; - } - else { - itemObj.selected = false; - } + for (let bubbleItem of CitationDataManager.items) { + bubbleItem.selected = selectedIDs.has(bubbleItem.id); } IOManager.updateBubbleInput(); } @@ -950,6 +939,10 @@ const IOManager = { let mode = _id("mode-button").getAttribute("mode"); newMode = mode == "library" ? "list" : "library"; } + // Do nothing if switching to a mode that is already active + let currentMode = _id("mode-button").getAttribute("mode"); + if (currentMode == newMode) return; + _id("list-layout").hidden = newMode == "library"; _id("library-layout").hidden = newMode == "list"; @@ -990,9 +983,15 @@ const IOManager = { updateBubbleInput() { // re-generate the bubble string for each item, in case a locator/prefix/suffix/etc. was changed for (let item of CitationDataManager.items) { - item.bubbleString = Helpers.buildBubbleString({ citationItem: item.citationItem, zoteroItem: item.zoteroItem }); + item.updateBubbleString(); } - _id("bubble-input").refresh(CitationDataManager.items); + _id("bubble-input").refresh(CitationDataManager.items.map((item) => { + return { + dialogReferenceID: item.dialogReferenceID, + bubbleString: item.bubbleString, + selected: item.selected, + }; + })); _id("accept-button").disabled = !CitationDataManager.items.length; }, @@ -1005,7 +1004,8 @@ const IOManager = { if (isCitingNotes) { if (!items[0].isNote()) return; CitationDataManager.items = []; - await CitationDataManager.addItems({ citationItems: items }); + let bubbleItem = BubbleItem.fromItem(items[0]); + await CitationDataManager.addItems({ bubbleItems: [bubbleItem] }); accept(); return; } @@ -1019,18 +1019,13 @@ const IOManager = { // If multiple items are being added, only add ones that are not included in the citation if (items.length > 1) { - items = items.filter(item => !(item.id && CitationDataManager.getItems({ zoteroItemID: item.id }).length)); + items = items.filter(item => !(item.id && CitationDataManager.getItems({ itemID: item.id }).length)); } // If the last input has a locator, add it into the item let input = _id("bubble-input").getCurrentInput(); let inputValue = SearchHandler.cleanSearchQuery(input?.value || ""); let locator = Helpers.extractLocator(inputValue); - // If there is no locator, make sure we clear it from the citation item - for (let item of items) { - item.label = locator?.label || null; - item.locator = locator?.locator || null; - } // Add the item at a position based on current input if it is not explicitly specified if (index === null && input) { index = _id("bubble-input").getFutureBubbleIndex(); @@ -1040,7 +1035,15 @@ const IOManager = { input.remove(); } - await CitationDataManager.addItems({ citationItems: items, index }); + // Add entries into the citation with the current locator if specified + let bubbleItems = items.map(item => BubbleItem.fromItem(item)); + if (locator) { + for (let bubbleItem of bubbleItems) { + bubbleItem.locator = locator.locator; + bubbleItem.label = locator.label; + } + } + await CitationDataManager.addItems({ bubbleItems, index }); // Refresh the itemTree if in library mode if (currentLayout.type == "library") { libraryLayout.refreshItemsView(); @@ -1255,8 +1258,8 @@ const IOManager = { let bubble = input.previousElementSibling; let item = CitationDataManager.getItem({ dialogReferenceID: bubble?.getAttribute("dialogReferenceID") }); if (item && locator && locator.onlyLocator && bubble) { - item.citationItem.locator = locator.locator; - item.citationItem.label = locator.label; + item.locator = locator.locator; + item.label = locator.label; input.value = ""; input.dispatchEvent(new Event('input', { bubbles: true })); this.updateBubbleInput(); @@ -1308,8 +1311,8 @@ const IOManager = { }, _openItemDetailsPopup(dialogReferenceID) { - let { zoteroItem, citationItem } = CitationDataManager.getItem({ dialogReferenceID }); - PopupsHandler.openItemDetails(dialogReferenceID, zoteroItem, citationItem, Helpers.buildItemDescription(zoteroItem)); + let bubbleItem = CitationDataManager.getItem({ dialogReferenceID }); + PopupsHandler.openItemDetails(bubbleItem, Helpers.buildItemDescription(bubbleItem.item)); }, _handleInput({ query, eventType }) { @@ -1425,27 +1428,112 @@ const IOManager = { } }; +// Representation of a single entry in the citation. +class BubbleItem { + // Can be created from either Zotero.Item or citation item from io.citation.citationItems + static fromItem(item) { + let citationItem = {}; + return new BubbleItem({ item, citationItem }); + } + + static fromCitationItem(citationItem) { + let item; + if (io.customGetItem) { + item = io.customGetItem(citationItem); + } + if (!item) { + item = Zotero.Cite.getItem(citationItem.id); + } + return new BubbleItem({ item, citationItem }); + } + + constructor({ item, citationItem }) { + if (!item || !citationItem) { + throw new Error("Both Zotero.Item and citation item must be provided"); + } + this.item = item; + this.cslItemID = item.cslItemID; + this.cslItemData = item.cslItemData; + this.cslURIs = item.cslURIs; + + this.locator = citationItem.locator; + this.label = citationItem.label; + this.suffix = citationItem.suffix; + this.prefix = citationItem.prefix; + this.suppressAuthor = citationItem["suppress-author"]; + + this.bubbleString = ""; + this.selected = false; + // Add a new ID to our citation item and set the same ID on the bubble + // so we have a reliable way to identify which bubble refers to which citationItem. + this.dialogReferenceID = Zotero.Utilities.randomString(5); + this.updateBubbleString(); + } + + get id() { + return this.cslItemID || this.item.id; + } + + updateBubbleString() { + this.bubbleString = Helpers.buildBubbleString(this); + } + + // Return an object with relevant fields that cipeproc can consume. + // Can optionally include dialogReferenceID for sorting in CitationDataManager.sort() + getCitationItem({ includeDialogReferenceID } = {}) { + let citationItem = { + id: this.id, + }; + if (this.locator) { + citationItem.locator = this.locator; + citationItem.label = this.label; + } + if (this.suffix) { + citationItem.suffix = this.suffix; + } + + if (this.prefix) { + citationItem.prefix = this.prefix; + } + if (this.suppressAuthor) { + citationItem["suppress-author"] = this.suppressAuthor; + } + if (this.cslItemData) { + citationItem.itemData = this.cslItemData; + } + if (this.cslURIs) { + citationItem.uris = this.cslURIs; + } + if (includeDialogReferenceID) { + citationItem.dialogReferenceID = this.dialogReferenceID; + } + return citationItem; + } +} + // // Singleton to store and handle items in this citation. -// CitationDataManager.items is an array of { zoteroItem, citationItem } objects, -// where zoteroItem is Zotero.Item and citationItem is a citation item provided by io. -// They are stored as a pair to make it easier to access both item properties (e.g. item.getDisplayTitle()) -// and properties of citation item (e.g. locator) across different components. +// CitationDataManager.items is an array of BubbleItem objects, which maps +// directly to the bubbles in the citation. // const CitationDataManager = { items: [], itemAddedCache: new Set(), - getCitationItems() { - return this.items.map(item => item.citationItem); + getCitedLibraryItemIDs() { + return new Set(this.items.map(item => item.item.id).filter(id => id)); + }, + + getSelectedIDs() { + return this.items.filter(item => item.selected).map(item => item.id); }, getItem({ dialogReferenceID }) { - return this.items.find(item => item.dialogReferenceID === dialogReferenceID); + return this.items.find(bubbleItem => bubbleItem.dialogReferenceID === dialogReferenceID); }, - getItems({ zoteroItemID }) { - return this.items.filter(item => item.zoteroItem.id === zoteroItemID); + getItems({ itemID }) { + return this.items.filter(bubbleItem => bubbleItem.item.id === itemID); }, getItemIndex({ dialogReferenceID }) { @@ -1454,28 +1542,28 @@ const CitationDataManager = { updateItemAddedCache() { this.itemAddedCache = new Set(); - for (let { zoteroItem } of this.items) { - if (!zoteroItem.id) continue; - this.itemAddedCache.add(zoteroItem.id); + for (let bubbleItem of this.items) { + if (!bubbleItem.item.id) continue; + this.itemAddedCache.add(bubbleItem.item.id.id); } }, - - // Include specified items into the citation - async addItems({ citationItems = [], index = null }) { - for (let item of citationItems) { - let zoteroItem = this._citationItemToZoteroItem(item); - // Add a new ID to our citation item and set the same ID on the bubble - // so we have a reliable way to identify which bubble refers to which citationItem. - let dialogReferenceID = Zotero.Utilities.randomString(5); - let toInsert = { citationItem: item, zoteroItem: zoteroItem, dialogReferenceID }; + + /** + * Include specified items into the citation. + + */ + async addItems({ bubbleItems = [], index = null }) { + for (let bubbleItem of bubbleItems) { if (index !== null) { - this.items.splice(index, 0, toInsert); + this.items.splice(index, 0, bubbleItem); index += 1; } else { - this.items.push(toInsert); + this.items.push(bubbleItem); } } + // No sorting happens when citing notes, since the dialog is accepted right after + if (isCitingNotes) return; await this.sort(); this.updateItemAddedCache(); }, @@ -1499,33 +1587,7 @@ const CitationDataManager = { // Update io citation object based on Citation.items array updateCitationObject(final = false) { - let result = []; - for (let item of this.items) { - let dialogReferenceID = item.dialogReferenceID; - item = item.citationItem; - if (item instanceof Zotero.Item) { - let ioResult = { id: item.cslItemID || item.id }; - if (typeof ioResult.id === "string" && ioResult.id.indexOf("/") !== -1) { - let item = Zotero.Cite.getItem(ioResult.id); - ioResult.uris = item.cslURIs; - ioResult.itemData = item.cslItemData; - } - ioResult.label = item.label || null; - ioResult.locator = item.locator || null; - ioResult.prefix = item.prefix || null; - ioResult.suffix = item.suffix || null; - ioResult['suppress-author'] = item["suppress-author"] || null; - result.push(ioResult); - } - else { - result.push(item); - } - if (!final) { - result[result.length - 1].dialogReferenceID = dialogReferenceID; - } - } - io.citation.citationItems = result; - + io.citation.citationItems = this.items.map(item => item.getCitationItem({ includeDialogReferenceID: !final })); if (final && io.sortable) { io.citation.properties.unsorted = !_id("keepSorted").checked; } @@ -1546,54 +1608,24 @@ const CitationDataManager = { // Construct citation upon initial load async buildCitation() { + let citationItems = []; if (!io.citation.properties.unsorted && _id("keepSorted").checked && io.citation.sortedItems?.length) { - await this.addItems({ citationItems: io.citation.sortedItems.map(entry => entry[1]) }); + citationItems = io.citation.sortedItems.map(entry => entry[1]); } else { - await this.addItems({ citationItems: io.citation.citationItems }); + citationItems = io.citation.citationItems; } + let bubbleItems = citationItems.map(item => BubbleItem.fromCitationItem(item)); + await this.addItems({ bubbleItems }); }, - - // Check if two given items are the same to prevent an item being inserted more - // than once into the citation. Compare firstCreator and title fields, instead of just - // itemIDs to account for cited items that may not have ids. - potentialDuplicateExists(targetZoteroItem) { - if (!(targetZoteroItem instanceof Zotero.Item)) { - targetZoteroItem = this._citationItemToZoteroItem(targetZoteroItem); - } - for (let item of this.items) { - let sameCreator = item.zoteroItem.getField("firstCreator") === targetZoteroItem.getField("firstCreator"); - let sameTitle = item.zoteroItem.getDisplayTitle() === targetZoteroItem.getDisplayTitle(); - if (sameCreator && sameTitle) return true; - } - return false; - }, - - // check if items have the same id, comparing .cslItemID for cited items or .id for - // usual items - _itemsHaveSameID(itemOne, itemTwo) { - let itemOneID = itemOne.cslItemID || itemOne.id; - let itemTwoID = itemTwo.cslItemID || itemTwo.id; - if (!itemOneID || !itemTwoID) return false; - return itemOneID == itemTwoID; - }, - - // Shortcut to fetch Zotero.Item based on citationItem - _citationItemToZoteroItem(citationItem) { - if (citationItem instanceof Zotero.Item) return citationItem; - if (io.customGetItem) { - let item = io.customGetItem(citationItem); - if (item) return item; - } - if (citationItem.id) { - return Zotero.Cite.getItem(citationItem.id); - } - return null; - } }; +// Explicitly expose singletons to global window for tests +window.CitationDataManager = CitationDataManager; +window.IOManager = IOManager; + // Top level listeners window.addEventListener("load", onLoad); window.addEventListener("unload", onUnload); diff --git a/chrome/content/zotero/integration/citationDialog/helpers.mjs b/chrome/content/zotero/integration/citationDialog/helpers.mjs index b5d4317b00..c1034abc96 100644 --- a/chrome/content/zotero/integration/citationDialog/helpers.mjs +++ b/chrome/content/zotero/integration/citationDialog/helpers.mjs @@ -282,13 +282,13 @@ export class CitationDialogHelpers { return height + margins + border; } - buildBubbleString({ citationItem, zoteroItem }) { + buildBubbleString(bubbleItem) { // Creator var title; - var str = zoteroItem.getField("firstCreator"); + var str = bubbleItem.item.getField("firstCreator"); // Title, if no creator (getDisplayTitle in order to get case, e-mail, statute which don't have a title field) - title = zoteroItem.getDisplayTitle(); + title = bubbleItem.item.getDisplayTitle(); title = title.substr(0, 32) + (title.length > 32 ? "…" : ""); if (!str && title) { str = Zotero.getString("punctuation.openingQMark") + title + Zotero.getString("punctuation.closingQMark"); @@ -298,32 +298,32 @@ export class CitationDialogHelpers { } // Date - var date = zoteroItem.getField("date", true, true); + var date = bubbleItem.item.getField("date", true, true); if (date && (date = date.substr(0, 4)) !== "0000") { str += ", " + parseInt(date); } // Locator - if (citationItem.locator) { + if (bubbleItem.locator) { // Try to fetch the short form of the locator label. E.g. "p." for "page" // If there is no locator label, default to "page" for now - let label = (Zotero.Cite.getLocatorString(citationItem.label || 'page', 'short') || '').toLocaleLowerCase(); + let label = (Zotero.Cite.getLocatorString(bubbleItem.label || 'page', 'short') || '').toLocaleLowerCase(); - str += `, ${label} ${citationItem.locator}`; + str += `, ${label} ${bubbleItem.locator}`; } // Prefix - if (citationItem.prefix && Zotero.CiteProc.CSL.ENDSWITH_ROMANESQUE_REGEXP) { - let prefix = citationItem.prefix.substr(0, 10) + (citationItem.prefix.length > 10 ? "…" : ""); + if (bubbleItem.prefix && Zotero.CiteProc.CSL.ENDSWITH_ROMANESQUE_REGEXP) { + let prefix = bubbleItem.prefix.substr(0, 10) + (bubbleItem.prefix.length > 10 ? "…" : ""); str = prefix - + (Zotero.CiteProc.CSL.ENDSWITH_ROMANESQUE_REGEXP.test(citationItem.prefix) ? " " : "") + + (Zotero.CiteProc.CSL.ENDSWITH_ROMANESQUE_REGEXP.test(bubbleItem.prefix) ? " " : "") + str; } // Suffix - if (citationItem.suffix && Zotero.CiteProc.CSL.STARTSWITH_ROMANESQUE_REGEXP) { - let suffix = citationItem.suffix.substr(0, 10) + (citationItem.suffix.length > 10 ? "…" : ""); - str += (Zotero.CiteProc.CSL.STARTSWITH_ROMANESQUE_REGEXP.test(citationItem.suffix) ? " " : "") + suffix; + if (bubbleItem.suffix && Zotero.CiteProc.CSL.STARTSWITH_ROMANESQUE_REGEXP) { + let suffix = bubbleItem.suffix.substr(0, 10) + (bubbleItem.suffix.length > 10 ? "…" : ""); + str += (Zotero.CiteProc.CSL.STARTSWITH_ROMANESQUE_REGEXP.test(bubbleItem.suffix) ? " " : "") + suffix; } return str; diff --git a/chrome/content/zotero/integration/citationDialog/popupHandler.mjs b/chrome/content/zotero/integration/citationDialog/popupHandler.mjs index dbf43f279e..9f1f078aaa 100644 --- a/chrome/content/zotero/integration/citationDialog/popupHandler.mjs +++ b/chrome/content/zotero/integration/citationDialog/popupHandler.mjs @@ -30,8 +30,7 @@ export class CitationDialogPopupsHandler { constructor({ doc }) { this.doc = doc; - this.item = null; - this.citationItem = null; + this.bubbleItem = null; this.discardItemDetailsEdits = false; this.itemDetailsWhenOpened = {}; this.itemDetailsTimeOpened = null; @@ -60,7 +59,7 @@ export class CitationDialogPopupsHandler { let event = new CustomEvent("delete-item", { bubbles: true, detail: { - dialogReferenceID: this.dialogReferenceID + dialogReferenceID: this.bubbleItem.dialogReferenceID } }); this.doc.dispatchEvent(event); @@ -71,7 +70,7 @@ export class CitationDialogPopupsHandler { this._getNode("#itemDetails .show").addEventListener("click", (_) => { this.discardItemDetailsEdits = true; this._getNode("#itemDetails").hidePopup(); - Zotero.Utilities.Internal.showInLibrary(this.item); + Zotero.Utilities.Internal.showInLibrary(this.bubbleItem.item); }); this._getNode("#itemDetails .done").addEventListener("click", (_) => { this._getNode("#itemDetails").hidePopup(); @@ -86,20 +85,18 @@ export class CitationDialogPopupsHandler { }, true); } - openItemDetails(dialogReferenceID, item, citationItem, itemDescription) { - this.item = item; - this.citationItem = citationItem; - this.dialogReferenceID = dialogReferenceID; + openItemDetails(bubbleItem, itemDescription) { + this.bubbleItem = bubbleItem; // record initial properties when popup is opened to be able to discard edits on Escape this.itemDetailsWhenOpened = { - label: citationItem.label, - locator: citationItem.locator, - prefix: citationItem.prefix, - suffix: citationItem.suffix, - suppressAuthor: citationItem["suppress-author"] + label: bubbleItem.label, + locator: bubbleItem.locator, + prefix: bubbleItem.prefix, + suffix: bubbleItem.suffix, + suppressAuthor: bubbleItem.suppressAuthor }; - let bubble = this._getNode(`[dialogReferenceID='${dialogReferenceID}']`); + let bubble = this._getNode(`[dialogReferenceID='${this.bubbleItem.dialogReferenceID}']`); let bubbleRect = bubble.getBoundingClientRect(); let popup = this._getNode("#itemDetails"); popup.openPopup(bubble, "after_start", 0, 4, false, false, null); @@ -118,21 +115,21 @@ export class CitationDialogPopupsHandler { this._getNode("#label").appendChild(option); } } - this._getNode("#itemDetails .show").hidden = !this.item.id; + this._getNode("#itemDetails .show").hidden = !this.bubbleItem.item.id; // Add header and fill inputs with their values let description = itemDescription; this._getNode("#itemDetails").querySelector(".description")?.remove(); - this._getNode("#itemTitle").textContent = this.item.getDisplayTitle(); + this._getNode("#itemTitle").textContent = this.bubbleItem.item.getDisplayTitle(); this._getNode("#itemTitle").after(description); - let dataTypeLabel = this.item.getItemTypeIconName(true); + let dataTypeLabel = this.bubbleItem.item.getItemTypeIconName(true); this._getNode("#itemDetails").querySelector(".icon").setAttribute("data-item-type", dataTypeLabel); - this._getNode("#label").value = this.citationItem.label || "page"; - this._getNode("#locator").value = this.citationItem.locator || ""; - this._getNode("#prefix").value = this.citationItem.prefix || ""; - this._getNode("#suffix").value = this.citationItem.suffix || ""; - this._getNode("#suppress-author").checked = !!this.citationItem["suppress-author"]; + this._getNode("#label").value = this.bubbleItem.label || "page"; + this._getNode("#locator").value = this.bubbleItem.locator || ""; + this._getNode("#prefix").value = this.bubbleItem.prefix || ""; + this._getNode("#suffix").value = this.bubbleItem.suffix || ""; + this._getNode("#suppress-author").checked = !!this.bubbleItem.suppressAuthor; bubble.classList.add("showingDetails"); this.itemDetailsTimeOpened = (new Date()).getTime(); } @@ -156,17 +153,17 @@ export class CitationDialogPopupsHandler { // When item details popup is closed, sync it's data to citationItems handleItemDetailsClosure() { - let bubble = this._getNode(`[dialogReferenceID='${this.dialogReferenceID}']`); + let bubble = this._getNode(`[dialogReferenceID='${this.bubbleItem.dialogReferenceID}']`); if (!bubble) return; bubble.classList.remove("showingDetails"); // Restore properties to what they were when popup opened if (this.discardItemDetailsEdits) { this.discardItemDetailsEdits = false; - this.citationItem.label = this.itemDetailsWhenOpened.label; - this.citationItem.locator = this.itemDetailsWhenOpened.locator; - this.citationItem.prefix = this.itemDetailsWhenOpened.prefix; - this.citationItem.suffix = this.itemDetailsWhenOpened.suffix; - this.citationItem["suppress-author"] = this.itemDetailsWhenOpened.suppressAuthor; + this.bubbleItem.label = this.itemDetailsWhenOpened.label; + this.bubbleItem.locator = this.itemDetailsWhenOpened.locator; + this.bubbleItem.prefix = this.itemDetailsWhenOpened.prefix; + this.bubbleItem.suffix = this.itemDetailsWhenOpened.suffix; + this.bubbleItem.suppressAuthor = this.itemDetailsWhenOpened.suppressAuthor; this.itemDetailsWhenOpened = {}; this.notifyCitationDialogOfChange(); } @@ -182,11 +179,11 @@ export class CitationDialogPopupsHandler { // Update item details and notify citation dialog about changes handleItemDetailsChange() { - this.citationItem.label = this._getNode("#locator").value ? this._getNode("#label").value : null; - this.citationItem.locator = this._getNode("#locator").value; - this.citationItem.prefix = this._getNode("#prefix").value; - this.citationItem.suffix = this._getNode("#suffix").value; - this.citationItem["suppress-author"] = this._getNode("#suppress-author").checked; + this.bubbleItem.label = this._getNode("#locator").value ? this._getNode("#label").value : null; + this.bubbleItem.locator = this._getNode("#locator").value; + this.bubbleItem.prefix = this._getNode("#prefix").value; + this.bubbleItem.suffix = this._getNode("#suffix").value; + this.bubbleItem.suppressAuthor = this._getNode("#suppress-author").checked; this.notifyCitationDialogOfChange(); } @@ -195,7 +192,7 @@ export class CitationDialogPopupsHandler { let event = new CustomEvent("item-details-updated", { bubbles: true, detail: { - dialogReferenceID: this.dialogReferenceID + dialogReferenceID: this.bubbleItem.dialogReferenceID } }); this.doc.dispatchEvent(event); diff --git a/chrome/content/zotero/integration/citationDialog/searchHandler.mjs b/chrome/content/zotero/integration/citationDialog/searchHandler.mjs index 8aac1d1958..365cab40b5 100644 --- a/chrome/content/zotero/integration/citationDialog/searchHandler.mjs +++ b/chrome/content/zotero/integration/citationDialog/searchHandler.mjs @@ -91,10 +91,9 @@ export class CitationDialogSearchHandler { // by the number of results in each library. // Items/notes in the libraries group are sorted via _createItemsSort/_createNotesSort comparators. // Takes citedItems as a parameter to filter them out from Selected, Opened and Cited groups. - getOrderedSearchResultGroups(citedItems = []) { + getOrderedSearchResultGroups(citedItemIDs = new Set()) { let removeItemsIncludedInCitation = (items) => { - let citedItemsIDs = new Set(citedItems.map(item => item.cslItemID || item.id)); - return items.filter(i => !citedItemsIDs.has(i.cslItemID ? i.cslItemID : i.id)); + return items.filter(i => !citedItemIDs.has(i.cslItemID ? i.cslItemID : i.id)); }; let result = []; // selected/open/cited go first @@ -184,6 +183,7 @@ export class CitationDialogSearchHandler { else { this.results.cited = this.searchValue ? this._filterNonMatchingItems(this.citedItems) : []; } + this._deduplicate(); } // clear selected/open items cache to re-fetch those items @@ -219,13 +219,16 @@ export class CitationDialogSearchHandler { // make sure that each item appears only in one group. // Items that are selected are removed from opened. - // Items that are selected or opened are removed from library results. + // Items that are selected or opened are removed from cited. + // Items that are selected or opened or cited are removed from library results. _deduplicate() { let selectedIDs = new Set(this.results.selected.map(item => item.id)); let openIDs = new Set(this.results.open.map(item => item.id)); + let citedIDs = new Set(this.results.cited.map(item => item.id)); this.results.open = this.results.open.filter(item => !selectedIDs.has(item.id)); - this.results.found = this.results.found.filter(item => !selectedIDs.has(item.id) && !openIDs.has(item.id)); + this.results.cited = this.results.cited.filter(item => !selectedIDs.has(item.id) && !openIDs.has(item.id)); + this.results.found = this.results.found.filter(item => !selectedIDs.has(item.id) && !openIDs.has(item.id) && !citedIDs.has(item.id)); } // Run the actual search query and find all items matching query across all libraries diff --git a/chrome/content/zotero/xpcom/editorInstance.js b/chrome/content/zotero/xpcom/editorInstance.js index affd0253d2..9b3f53a184 100644 --- a/chrome/content/zotero/xpcom/editorInstance.js +++ b/chrome/content/zotero/xpcom/editorInstance.js @@ -1079,6 +1079,10 @@ class EditorInstance { else if (!citationItem.id && citationItem.itemData) { let item = new Zotero.Item(); Zotero.Utilities.itemFromCSLJSON(item, citationItem.itemData); + // Add csl data in the same format as in Zotero.Integration.Citation.loadItemData + item.cslItemID = citationItem.id; + item.cslURIs = citationItem.uris; + item.cslItemData = citationItem.itemData; return item; } // Otherwise returns `undefined` which makes this function to be diff --git a/test/tests/citationDialogTest.js b/test/tests/citationDialogTest.js index 6c5a196143..c39b4f6aa0 100644 --- a/test/tests/citationDialogTest.js +++ b/test/tests/citationDialogTest.js @@ -5,6 +5,7 @@ describe("Citation Dialog", function () { sort() {}, sortable: false, citation: { + citationItems: [], properties: { unsorted: false, } @@ -13,7 +14,7 @@ describe("Citation Dialog", function () { return []; } }; - let dialog, win; + let dialog, win, IOManager, CitationDataManager, SearchHandler; before(async function () { // one of helper functions of searchHandler uses zotero pane @@ -21,11 +22,22 @@ describe("Citation Dialog", function () { let dialogPromise = waitForWindow("chrome://zotero/content/integration/citationDialog.xhtml"); Services.ww.openWindow(null, "chrome://zotero/content/integration/citationDialog.xhtml", "", "", io); dialog = await dialogPromise; + IOManager = dialog.IOManager; + CitationDataManager = dialog.CitationDataManager; + SearchHandler = dialog.SearchHandler; // wait for everything (e.g. itemTree/collectionTree) inside of the dialog to be loaded. - // it is not used currently but may be required when more complex tests are added - // while (!dialog.loaded) { - // await Zotero.Promise.delay(10); - // } + while (!dialog.loaded) { + await Zotero.Promise.delay(10); + } + }); + + beforeEach(async function () { + // Many operations (e.g. IOManager.addItemsToCitation) are disabled + // when search runs. Search can be triggered by a variety of events + // so before each test, we make sure that search has finished running + while (SearchHandler.searching) { + await Zotero.Promise.delay(10); + } }); after(function () { @@ -33,6 +45,424 @@ describe("Citation Dialog", function () { win.close(); }); + describe("Manage entries in the citation", function () { + let citedItemNotInLibrary = { + id: "o7HzMbH6/6iMXHx6s", + itemData: { + id: "o7HzMbH6/6iMXHx6s", + type: "book", + title: "cited_not_in_library_test_title", + author: [ + { + family: "Last", + given: "First" + } + ] + }, + uris: [ + "http://zotero.org/users/11573780/items/K22KNVZL" + ], + item: { + id: "o7HzMbH6/6iMXHx6s", + type: "book", + title: "cited_not_in_library_test_title", + author: [ + { + family: "Last", + given: "First" + } + ], + "title-main": "cited_not_in_library_test_title", + "title-sub": "", + "title-subjoin": "" + }, + label: undefined, + locator: undefined, + prefix: undefined, + suffix: undefined, + "suppress-author": undefined + }; + let citedItemOne = { + id: null, + item: { + id: null, + type: "book", + title: "cited_in_library_test_title", + author: [ + { + family: "Last", + given: "First" + } + ], + "title-main": "cited_in_library_test_title", + "title-sub": "", + "title-subjoin": "" + }, + label: undefined, + locator: undefined, + prefix: undefined, + suffix: undefined, + "suppress-author": undefined + }; + let itemOne, itemTwo, bubbleInput, ZoteroCiteGetItemStub, surrogateCitedItem; + + before(async function () { + bubbleInput = dialog.document.querySelector("bubble-input"); + + // Virtual Zotero.Item for a cited item that does not exist in the library. + // Same logic as in Zotero.Integration.Citation.loadItemData. + surrogateCitedItem = new Zotero.Item(); + Zotero.Utilities.itemFromCSLJSON(surrogateCitedItem, citedItemNotInLibrary.itemData); + surrogateCitedItem.cslItemID = citedItemNotInLibrary.id; + surrogateCitedItem.cslURIs = citedItemNotInLibrary.uris; + surrogateCitedItem.cslItemData = citedItemNotInLibrary.itemData; + // Zotero.Cite.getItem called with citedItemNotInLibrary returns virtual Zotero.Item from above + ZoteroCiteGetItemStub = sinon.stub(Zotero.Cite, 'getItem').callsFake(function (id) { + if (id === citedItemNotInLibrary.id) { + return surrogateCitedItem; + } + return Zotero.Items.get(id); + }); + + itemOne = await createDataObject('item', { title: "one" }); + itemOne.setCreators([ + { + firstName: "First_One", + lastName: "Last_One", + creatorType: "author" + } + ]); + await itemOne.saveTx(); + // citedItemOne is an earlier cited itemOne + citedItemOne.id = itemOne.id; + citedItemOne.item.id = itemOne.id; + + itemTwo = await createDataObject('item', { title: "two" }); + itemTwo.setCreators([ + { + firstName: "First_Two", + lastName: "Last_Two", + creatorType: "author" + } + ]); + await itemTwo.saveTx(); + }); + + after(function () { + ZoteroCiteGetItemStub.restore(); + }); + + beforeEach(function () { + io.citation.citationItems = []; + io.citation.sortable = false; + dialog.document.getElementById("keepSorted").checked = false; + io.sort = () => {}; + CitationDataManager.items = []; + IOManager.updateBubbleInput(); + }); + + it("should add an item to citation", async function () { + await IOManager.addItemsToCitation([itemOne]); + + let bubbles = bubbleInput.getAllBubbles(); + assert.equal(CitationDataManager.items.length, 1); + assert.equal(bubbles.length, 1); + assert.equal(bubbles[0].textContent, itemOne.getCreator(0).lastName); + }); + + it("should remove an item from citation", async function () { + await IOManager.addItemsToCitation([itemOne, itemTwo]); + let bubbles = bubbleInput.getAllBubbles(); + assert.equal(bubbles.length, 2); + + let firstBubbleItem = CitationDataManager.items[0]; + IOManager._deleteItem(firstBubbleItem.dialogReferenceID); + + bubbles = bubbleInput.getAllBubbles(); + assert.equal(CitationDataManager.items.length, 1); + assert.equal(CitationDataManager.items[0].id, itemTwo.id); + assert.equal(bubbles.length, 1); + assert.equal(bubbles[0].textContent, itemTwo.getCreator(0).lastName); + }); + + it("should build citation with a cited item in library", async function () { + io.citation.citationItems = [citedItemOne]; + + await CitationDataManager.buildCitation(); + IOManager.updateBubbleInput(); + + let bubbles = bubbleInput.getAllBubbles(); + assert.equal(CitationDataManager.items.length, 1); + assert.equal(bubbles.length, 1); + assert.equal(bubbles[0].textContent, itemOne.getCreator(0).lastName); + }); + + it("should build citation with a cited item not in library", async function () { + io.citation.citationItems = [citedItemNotInLibrary]; + + await CitationDataManager.buildCitation(); + IOManager.updateBubbleInput(); + + let bubbles = bubbleInput.getAllBubbles(); + assert.equal(CitationDataManager.items.length, 1); + assert.equal(bubbles.length, 1); + assert.equal(bubbles[0].textContent, surrogateCitedItem.getCreator(0).lastName); + }); + + it("should add a locator/suffix/prefix to a bubble", async function () { + // add two bubbles for the same item + await IOManager.addItemsToCitation([itemOne, itemOne]); + assert.equal(CitationDataManager.items.length, 2); + + // open popup + let firstBubble = CitationDataManager.items[0]; + IOManager._openItemDetailsPopup(firstBubble.dialogReferenceID); + let popup = dialog.document.getElementById("itemDetails"); + + // give the popup time to open + await Zotero.Promise.delay(50); + assert.equal(popup.state, "open"); + + // set locator/suffix/prefix values + popup.querySelector("#locator").value = "10"; + popup.querySelector("#suffix").value = "suffix"; + popup.querySelector("#prefix").value = "prefix"; + popup.querySelector("#prefix").dispatchEvent(new Event('input', { bubbles: true })); + + // make sure they are set on the bubbleItem + assert.equal(firstBubble.locator, "10"); + assert.equal(firstBubble.suffix, "suffix"); + assert.equal(firstBubble.prefix, "prefix"); + let bubble = dialog.document.querySelector(`.bubble[dialogReferenceID="${firstBubble.dialogReferenceID}"]`); + assert.equal(bubble.textContent, "prefix Last_One, p. 10 suffix"); + + // make sure the other bubbleItem is not affected + let secondBubble = CitationDataManager.items[1]; + assert.notOk(secondBubble.locator); + assert.notOk(secondBubble.suffix); + assert.notOk(secondBubble.prefix); + }); + + it("should change the order of bubbles", async function () { + // add two items + await IOManager.addItemsToCitation([itemOne, itemTwo]); + let bubbleItemOne = CitationDataManager.items[0]; + let bubbleItemTwo = CitationDataManager.items[1]; + + // check initial order + let bubbles = bubbleInput.getAllBubbles(); + assert.equal(bubbles[0].getAttribute("dialogReferenceID"), bubbleItemOne.dialogReferenceID); + assert.equal(bubbles[1].getAttribute("dialogReferenceID"), bubbleItemTwo.dialogReferenceID); + + // move the second item to the first position + IOManager._moveItem(bubbleItemTwo.dialogReferenceID, 0); + + // ensure the order is correct + assert.equal(CitationDataManager.items[0].dialogReferenceID, bubbleItemTwo.dialogReferenceID); + assert.equal(CitationDataManager.items[1].dialogReferenceID, bubbleItemOne.dialogReferenceID); + bubbles = dialog.document.querySelector("bubble-input").getAllBubbles(); + assert.equal(bubbles[0].getAttribute("dialogReferenceID"), bubbleItemTwo.dialogReferenceID); + assert.equal(bubbles[1].getAttribute("dialogReferenceID"), bubbleItemOne.dialogReferenceID); + }); + + it("should sort the citation", async function () { + // Make dialog sortable + io.citation.sortable = true; + dialog.document.getElementById("keepSorted").checked = true; + // Mock sort.io implementation that sorts itemOne to the first position + io.sort = () => { + let items = io.citation.citationItems; + items.sort((a, b) => { + if (a.id === itemOne.id) return -1; + if (b.id === itemOne.id) return 1; + return 0; + }); + io.citation.sortedItems = [ + [null, items[0]], + [null, items[1]] + ]; + }; + + // Add items to citation in wrong order + await IOManager.addItemsToCitation([itemTwo, itemOne]); + + // Make sure the bubbleItems are sorted with itemOne being first + let firstBubbleItem = CitationDataManager.items[0]; + let secondBubbleItem = CitationDataManager.items[1]; + assert.equal(firstBubbleItem.id, itemOne.id); + assert.equal(secondBubbleItem.id, itemTwo.id); + let bubbles = bubbleInput.getAllBubbles(); + assert.equal(bubbles[0].getAttribute("dialogReferenceID"), firstBubbleItem.dialogReferenceID); + assert.equal(bubbles[1].getAttribute("dialogReferenceID"), secondBubbleItem.dialogReferenceID); + }); + + it("should update io.citation.items from bubbles", async function () { + let bubbleItems = CitationDataManager.items; + // Build citation with several cited items + io.citation.citationItems = [citedItemOne, citedItemNotInLibrary]; + await CitationDataManager.buildCitation(); + + // Add another item + await IOManager.addItemsToCitation([itemTwo], { index: 2 }); + + // Add modifications + bubbleItems[0].label = "page"; + bubbleItems[0].locator = "10"; + + bubbleItems[1].prefix = "prefix"; + bubbleItems[1].suffix = "suffix"; + + bubbleItems[2].suppressAuthor = true; + + // Update io.citation.items and make sure it looks right + CitationDataManager.updateCitationObject(true); + let expected = [ + { + id: itemOne.id, + locator: "10", + label: "page" + }, + { + id: citedItemNotInLibrary.id, + suffix: "suffix", + prefix: "prefix", + itemData: citedItemNotInLibrary.itemData, + uris: citedItemNotInLibrary.uris, + }, + { + id: itemTwo.id, + "suppress-author": true + } + ]; + assert.deepEqual(io.citation.citationItems, expected); + }); + }); + + describe("UI", function () { + beforeEach(function () { + CitationDataManager.items = []; + IOManager.updateBubbleInput(); + }); + + it("should switch dialog mode", async function () { + IOManager.toggleDialogMode("list"); + assert.isFalse(dialog.document.getElementById("list-layout").hidden); + assert.isTrue(dialog.document.getElementById("library-layout").hidden); + IOManager.toggleDialogMode("library"); + assert.isFalse(dialog.document.getElementById("library-layout").hidden); + assert.isTrue(dialog.document.getElementById("list-layout").hidden); + }); + + it("should highlight bubbles whose items are selected", async function () { + let itemOne = await createDataObject('item'); + let itemTwo = await createDataObject('item'); + + IOManager.toggleDialogMode("library"); + await IOManager.addItemsToCitation([itemOne, itemTwo]); + + // Select row of the first bubble + await dialog.libraryLayout.itemsView.selectItem(itemOne.id); + // Check that the bubble is highlighted + let bubbleOne = dialog.document.querySelector(`.bubble[dialogReferenceID="${CitationDataManager.items[0].dialogReferenceID}"]`); + assert.isTrue(bubbleOne.classList.contains("has-item-selected")); + // Check the other bubble is unaffected + let bubbleTwo = dialog.document.querySelector(`.bubble[dialogReferenceID="${CitationDataManager.items[1].dialogReferenceID}"]`); + assert.isFalse(bubbleTwo.classList.contains("has-item-selected")); + }); + + it("should highlight rows of items in the citation", async function () { + let itemOne = await createDataObject('item'); + IOManager.toggleDialogMode("library"); + + // Add the item to citation + await IOManager.addItemsToCitation([itemOne]); + // Select the row in itemTree, so it is visible + await dialog.libraryLayout.itemsView.selectItem(itemOne.id); + // Make sure the row node is highlighted + let rowIndex = dialog.libraryLayout.itemsView.getRowIndexByID(itemOne.id); + let rowID = "item-tree-citationDialog-row-" + rowIndex; + let rowNode = dialog.document.getElementById(rowID); + assert.isTrue(rowNode.classList.contains("highlighted")); + }); + }); + + describe("Search", function () { + let selectedOne, selectedTwo, openOne, openTwo, selectedAndOpenOne, citedOne, citedAndOpenOne, libraryOne, libraryTwo; + + before(async function () { + selectedOne = await createDataObject('item', { title: "one_selected" }); + selectedTwo = await createDataObject('item', { title: "two_selected" }); + openOne = await createDataObject('item', { title: "one_open" }); + openTwo = await createDataObject('item', { title: "two_open" }); + selectedAndOpenOne = await createDataObject('item', { title: "one_selected_open" }); + libraryOne = await createDataObject('item', { title: "one_library" }); + libraryTwo = await createDataObject('item', { title: "two_library" }); + citedOne = await createDataObject('item', { title: "one_cited" }); + citedAndOpenOne = await createDataObject('item', { title: "one_open_cited" }); + + // Present these items are selected/open/cited + SearchHandler.selectedItems = [selectedOne, selectedTwo, selectedAndOpenOne]; + SearchHandler.openItems = [openOne, openTwo, selectedAndOpenOne, citedAndOpenOne]; + SearchHandler.citedItems = [citedOne, citedAndOpenOne]; + }); + + after(function () { + SearchHandler.openItems = []; + SearchHandler.selectedItems = []; + }); + + it("should perform search in list mode", async function () { + IOManager.toggleDialogMode("list"); + + // Search for "one" + await dialog.currentLayout.search("one", { skipDebounce: true }); + // Selected items should have both "one_selected" and "one_selected_open" + let selectedIDs = SearchHandler.results.selected.map(item => item.id); + assert.sameMembers(selectedIDs, [selectedOne.id, selectedAndOpenOne.id]); + // Open items should have "one_open" and "one_open_cited" but not "one_selected_open", since it is selected + let openIDs = SearchHandler.results.open.map(item => item.id); + assert.sameMembers(openIDs, [openOne.id, citedAndOpenOne.id]); + // Cited items should have "one_cited" + let citedIDs = SearchHandler.results.cited.map(item => item.id); + assert.sameMembers(citedIDs, [citedOne.id]); + // Library items should have "one_library" but not "two_library", "one_cited", or "one_open_cited" + let libraryIDs = SearchHandler.results.found.map(item => item.id); + assert.include(libraryIDs, libraryOne.id); + assert.notInclude(libraryIDs, libraryTwo.id); + assert.notInclude(libraryIDs, citedOne.id); + // Make sure actual nodes for search matches are rendered + let expectedItemCardIDs = [...selectedIDs, ...openIDs, ...citedIDs, ...libraryIDs]; + for (let itemID of expectedItemCardIDs) { + let node = dialog.document.querySelector(`.item[id="${itemID}"]`); + assert.isOk(node); + } + }); + + it("should perform search in library mode", async function () { + IOManager.toggleDialogMode("library"); + + // Search for "one" + await dialog.currentLayout.search("one", { skipDebounce: true }); + // Selected items should have both "one_selected" and "one_selected_open" + let selectedIDs = SearchHandler.results.selected.map(item => item.id); + assert.sameMembers(selectedIDs, [selectedOne.id, selectedAndOpenOne.id]); + // Open items should have "one_open" and "one_open_cited" but not "one_selected_open", since it is selected + let openIDs = SearchHandler.results.open.map(item => item.id); + assert.sameMembers(openIDs, [openOne.id, citedAndOpenOne.id]); + // Cited items should have "one_cited" + let citedIDs = SearchHandler.results.cited.map(item => item.id); + assert.sameMembers(citedIDs, [citedOne.id]); + // In library mode, library is searched via itemTree, so this should be empty + assert.equal(SearchHandler.results.found.length, 0); + // Make sure actual nodes for search matches are rendered + let expectedItemCardIDs = [...selectedIDs, ...openIDs, ...citedIDs]; + for (let itemID of expectedItemCardIDs) { + let node = dialog.document.querySelector(`.item[id="${itemID}"]`); + assert.isOk(node); + } + }); + }); + describe("Helpers.extractLocator", function () { let locator; describe("Invalid locators", function () {