describe("Plugin API", function () { var win, doc, ZoteroPane, Zotero_Tabs, ZoteroContextPane, _itemsView, infoSection, caches; function resetCaches() { caches = {}; } function initCache(key) { if (!caches[key]) { caches[key] = {}; } caches[key].deferred = Zotero.Promise.defer(); caches[key].result = ""; } async function getCache(key) { let cache = caches[key]; await cache.deferred.promise; return cache.result; } function updateCache(key, value) { let cache = caches[key]; if (!cache) return; cache.result = value; cache.deferred?.resolve(); } before(async function () { win = await loadZoteroPane(); doc = win.document; ZoteroPane = win.ZoteroPane; Zotero_Tabs = win.Zotero_Tabs; ZoteroContextPane = win.ZoteroContextPane; _itemsView = win.ZoteroPane.itemsView; infoSection = win.ZoteroPane.itemPane._itemDetails.getPane('info'); }); after(function () { win.close(); }); describe("Item pane info box custom section", function () { let defaultOption = { rowID: "default-test", pluginID: "zotero@zotero.org", label: { l10nID: "general-print", }, onGetData: ({ item }) => { let data = `${item.id}`; updateCache("onGetData", data); return data; }, }; let waitForRegister = async (option) => { initCache("onGetData"); let getDataPromise = getCache("onGetData"); let rowID = Zotero.ItemPaneManager.registerInfoRow(option); await getDataPromise; return rowID; }; let waitForUnregister = async (rowID) => { let unregisterPromise = waitForNotifierEvent("refresh", "infobox"); let success = Zotero.ItemPaneManager.unregisterInfoRow(rowID); await unregisterPromise; return success; }; beforeEach(async function () { resetCaches(); }); afterEach(function () { Zotero_Tabs.select("zotero-pane"); Zotero_Tabs.closeAll(); }); it("should render custom row and call onGetData hook", async function () { initCache("onGetData"); let item = new Zotero.Item('book'); await item.saveTx(); await ZoteroPane.selectItem(item.id); let getDataPromise = getCache("onGetData"); let rowID = Zotero.ItemPaneManager.registerInfoRow(defaultOption); let result = await getDataPromise; // Should render custom row let rowElem = infoSection.querySelector(`[data-custom-row-id="${rowID}"]`); assert.exists(rowElem); // Should call onGetData and render let valueElem = rowElem.querySelector(".value"); assert.equal(result, valueElem.value); await waitForUnregister(rowID); }); it("should call onSetData hook", async function () { let option = Object.assign({}, defaultOption, { onSetData: ({ value }) => { let data = `${value}`; updateCache("onSetData", data); }, }); let item = new Zotero.Item('book'); await item.saveTx(); await ZoteroPane.selectItem(item.id); let rowID = await waitForRegister(option); let rowElem = infoSection.querySelector(`[data-custom-row-id="${rowID}"]`); let valueElem = rowElem.querySelector(".value"); // Should call onSetData on value change initCache("onSetData"); let setDataPromise = getCache("onSetData"); let newValue = `TEST CUSTOM ROW`; valueElem.focus(); valueElem.value = newValue; let blurEvent = new Event("blur"); valueElem.dispatchEvent(blurEvent); let result = await setDataPromise; assert.equal(newValue, result); await waitForUnregister(rowID); }); it("should call onItemChange hook", async function () { let option = Object.assign({}, defaultOption, { onItemChange: ({ item, tabType, setEnabled, setEditable }) => { let editable = item.itemType === "book"; let enabled = tabType === "library"; setEnabled(enabled); setEditable(editable); let data = { editable, enabled }; updateCache("onItemChange", data); } }); initCache("onItemChange"); let bookItem = new Zotero.Item('book'); await bookItem.saveTx(); await ZoteroPane.selectItem(bookItem.id); let itemChangePromise = getCache("onItemChange"); let rowID = await waitForRegister(option); let result = await itemChangePromise; let rowElem = infoSection.querySelector(`[data-custom-row-id="${rowID}"]`); let valueElem = rowElem.querySelector(".value"); // Should be enabled and editable assert.isTrue(result.editable); assert.isFalse(valueElem.readOnly); assert.isTrue(result.enabled); assert.isFalse(rowElem.hidden); initCache("onItemChange"); itemChangePromise = getCache("onItemChange"); let docItem = new Zotero.Item('document'); await docItem.saveTx(); await ZoteroPane.selectItem(docItem.id); result = await itemChangePromise; // Should be enabled and not editable assert.isFalse(result.editable); assert.isTrue(valueElem.readOnly); assert.isTrue(result.enabled); assert.isFalse(rowElem.hidden); let file = getTestDataDirectory(); file.append('test.pdf'); let attachment = await Zotero.Attachments.importFromFile({ file, parentItemID: docItem.id }); initCache("onItemChange"); itemChangePromise = getCache("onItemChange"); 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"); result = await itemChangePromise; let itemDetails = ZoteroContextPane.context._getItemContext(tabID); rowElem = itemDetails.getPane("info").querySelector(`[data-custom-row-id="${rowID}"]`); valueElem = rowElem.querySelector(".value"); // Should not be enabled in non-library tab assert.isFalse(result.enabled); assert.isTrue(rowElem.hidden); await waitForUnregister(rowID); }); it("should render row at position", async function () { let startOption = Object.assign({}, defaultOption, { position: "start", }); let afterCreatorsOption = Object.assign({}, defaultOption, { position: "afterCreators", }); let endOption = Object.assign({}, defaultOption, { position: "end", }); let item = new Zotero.Item('book'); await item.saveTx(); await ZoteroPane.selectItem(item.id); // Row at start let rowID = await waitForRegister(startOption); let rowElem = infoSection.querySelector(`[data-custom-row-id="${rowID}"]`); assert.notExists(rowElem.previousElementSibling); await waitForUnregister(rowID); // Row after creator rows rowID = await waitForRegister(afterCreatorsOption); rowElem = infoSection.querySelector(`[data-custom-row-id="${rowID}"]`); assert.exists(rowElem.previousElementSibling.querySelector(".creator-type-value")); assert.notExists(rowElem.nextElementSibling.querySelector(".creator-type-value")); await waitForUnregister(rowID); // Row at end rowID = rowID = await waitForRegister(endOption); rowElem = infoSection.querySelector(`[data-custom-row-id="${rowID}"]`); assert.exists(rowElem.nextElementSibling.querySelector("*[fieldname=dateAdded]")); await waitForUnregister(rowID); }); it("should set input editable", async function () { let editableOption = Object.assign({}, defaultOption, { editable: true, }); let notEditableOption = Object.assign({}, defaultOption, { editable: false, }); let item = new Zotero.Item('book'); await item.saveTx(); await ZoteroPane.selectItem(item.id); let rowID = await waitForRegister(defaultOption); let rowElem = infoSection.querySelector(`[data-custom-row-id="${rowID}"]`); let valueElem = rowElem.querySelector(".value"); assert.isFalse(valueElem.readOnly); await waitForUnregister(rowID); rowID = await waitForRegister(editableOption); rowElem = infoSection.querySelector(`[data-custom-row-id="${rowID}"]`); valueElem = rowElem.querySelector(".value"); assert.isFalse(valueElem.readOnly); await waitForUnregister(rowID); rowID = await waitForRegister(notEditableOption); rowElem = infoSection.querySelector(`[data-custom-row-id="${rowID}"]`); valueElem = rowElem.querySelector(".value"); assert.isTrue(valueElem.readOnly); await waitForUnregister(rowID); }); it("should set input multiline", async function () { let multilineOption = Object.assign({}, defaultOption, { multiline: true, }); let notMultilineOption = Object.assign({}, defaultOption, { multiline: false, }); let item = new Zotero.Item('book'); await item.saveTx(); await ZoteroPane.selectItem(item.id); let rowID = await waitForRegister(defaultOption); let rowElem = infoSection.querySelector(`[data-custom-row-id="${rowID}"]`); let valueElem = rowElem.querySelector(".value"); assert.isFalse(valueElem.multiline); await waitForUnregister(rowID); rowID = await waitForRegister(multilineOption); rowElem = infoSection.querySelector(`[data-custom-row-id="${rowID}"]`); valueElem = rowElem.querySelector(".value"); assert.isTrue(valueElem.multiline); await waitForUnregister(rowID); rowID = await waitForRegister(notMultilineOption); rowElem = infoSection.querySelector(`[data-custom-row-id="${rowID}"]`); valueElem = rowElem.querySelector(".value"); assert.isFalse(valueElem.multiline); await waitForUnregister(rowID); }); it("should set input nowrap", async function () { let noWrapOption = Object.assign({}, defaultOption, { nowrap: true, }); let wrapOption = Object.assign({}, defaultOption, { nowrap: false, }); let item = new Zotero.Item('book'); await item.saveTx(); await ZoteroPane.selectItem(item.id); let rowID = await waitForRegister(defaultOption); let rowElem = infoSection.querySelector(`[data-custom-row-id="${rowID}"]`); let valueElem = rowElem.querySelector(".value"); assert.isFalse(valueElem.noWrap); await waitForUnregister(rowID); rowID = await waitForRegister(noWrapOption); rowElem = infoSection.querySelector(`[data-custom-row-id="${rowID}"]`); valueElem = rowElem.querySelector(".value"); assert.isTrue(valueElem.noWrap); await waitForUnregister(rowID); rowID = await waitForRegister(wrapOption); rowElem = infoSection.querySelector(`[data-custom-row-id="${rowID}"]`); valueElem = rowElem.querySelector(".value"); assert.isFalse(valueElem.noWrap); await waitForUnregister(rowID); }); it("should refresh custom row value", async function () { let item = new Zotero.Item('book'); await item.saveTx(); await ZoteroPane.selectItem(item.id); let rowID = await waitForRegister(defaultOption); let rowElem = infoSection.querySelector(`[data-custom-row-id="${rowID}"]`); let valueElem = rowElem.querySelector(".value"); let oldValue = valueElem.value; // Since this row does not have `onSetData`, changing value does not do anything // We just want to test if the value can be refreshed by calling `updateInfoRow` let newValue = "TEST CUSTOM ROW EDITED"; valueElem.value = newValue; let notifyPromise = waitForNotifierEvent("refresh", "infobox"); // Manually refresh the row Zotero.ItemPaneManager.refreshInfoRow(rowID); await notifyPromise; assert.equal(oldValue, valueElem.value); await waitForUnregister(rowID); }); it("should render custom row value after item change", async function () { // https://github.com/zotero/zotero/issues/4874 let item = new Zotero.Item('book'); await item.saveTx(); await ZoteroPane.selectItem(item.id); let rowID = await waitForRegister(defaultOption); let rowElem = infoSection.querySelector(`[data-custom-row-id="${rowID}"]`); let valueElem = rowElem.querySelector(".value"); let value = valueElem.value; assert.equal(`${item.id}`, value); initCache("onGetData"); let getDataPromise = getCache("onGetData"); let docItem = new Zotero.Item('document'); await docItem.saveTx(); await ZoteroPane.selectItem(docItem.id); await getDataPromise; value = valueElem.value; assert.equal(`${docItem.id}`, value); await waitForUnregister(rowID); }); }); describe("Item tree custom column", function () { // Only test hooks, as other column options are covered in item tree tests let defaultOption = { columnID: "default-test", pluginID: "zotero@zotero.org", dataKey: "api-test", label: "APITest", dataProvider: (item) => { let data = `${item.id}`; updateCache("dataProvider", data); return data; }, }; let waitForRegister = async (option) => { initCache("dataProvider"); let getDataPromise = getCache("dataProvider"); let columnKey = Zotero.ItemTreeManager.registerColumn(option); await getDataPromise; return columnKey; }; let waitForColumnEnable = async (dataKey) => { _itemsView._columnPrefs[dataKey] = { dataKey, hidden: false, }; let columns = _itemsView._getColumns(); let columnID = columns.findIndex(column => column.dataKey === dataKey); if (columnID === -1) { return; } let column = columns[columnID]; if (!column.hidden) { return; } _itemsView.tree._columns.toggleHidden(columnID); // Wait for column header to render await waitForCallback( () => !!doc.querySelector(`#zotero-items-tree .virtualized-table-header .cell.${dataKey}`), 100, 3); }; let waitForUnregister = async (columnID) => { let unregisterPromise = waitForNotifierEvent("refresh", "itemtree"); let success = Zotero.ItemTreeManager.unregisterColumn(columnID); await unregisterPromise; return success; }; let getSelectedRowCell = (dataKey) => { let cell = doc.querySelector(`#zotero-items-tree .row.selected .${dataKey}`); return cell; }; beforeEach(async function () { resetCaches(); }); afterEach(function () { Zotero_Tabs.select("zotero-pane"); Zotero_Tabs.closeAll(); }); it("should render custom column and call dataProvider hook", async function () { let item = new Zotero.Item('book'); await item.saveTx(); await ZoteroPane.selectItem(item.id); let columnKey = await waitForRegister(defaultOption); await waitForColumnEnable(columnKey); // Should render custom column cell let cellElem = getSelectedRowCell(columnKey); assert.exists(cellElem); // Should call dataProvider and render the value assert.equal(`${item.id}`, cellElem.textContent); await waitForUnregister(columnKey); }); it("should use custom renderCell hook", async function () { let customCellContent = "Custom renderCell"; let option = Object.assign({}, defaultOption, { renderCell: (index, data, column, isFirstColumn, doc) => { // index: the index of the row // data: the data to display in the column, return of `dataProvider` // column: the column options // isFirstColumn: true if this is the first column // doc: the document of the item tree // return: the HTML to display in the cell const cell = doc.createElement('span'); cell.className = `cell ${column.className}`; cell.textContent = customCellContent; cell.style.color = 'red'; updateCache("renderCell", cell.textContent); return cell; }, }); let item = new Zotero.Item('book'); await item.saveTx(); await ZoteroPane.selectItem(item.id); let columnKey = await waitForRegister(option); await waitForColumnEnable(columnKey); // Should render custom column cell let cellElem = getSelectedRowCell(columnKey); assert.exists(cellElem); // Should call renderCell and render the value assert.equal('rgb(255, 0, 0)', win.getComputedStyle(cellElem).color); await waitForUnregister(columnKey); }); it("should not break ui when hooks throw error", async function () { let option = Object.assign({}, defaultOption, { dataProvider: () => { updateCache("dataProvider", "Test error"); throw new Error("Test error"); }, renderCell: () => { updateCache("renderCell", "Test error"); throw new Error("Test error"); } }); let item = new Zotero.Item('book'); await item.saveTx(); await ZoteroPane.selectItem(item.id); let columnKey = await waitForRegister(option); await waitForColumnEnable(columnKey); // Should not break ui let columnElem = getSelectedRowCell(columnKey); assert.exists(columnElem); await waitForUnregister(columnKey); }); }); });