diff --git a/src/components/__tests__/CustomListEntriesEditor-test.tsx b/src/components/__tests__/CustomListEntriesEditor-test.tsx
deleted file mode 100644
index 880ca46b43..0000000000
--- a/src/components/__tests__/CustomListEntriesEditor-test.tsx
+++ /dev/null
@@ -1,1461 +0,0 @@
-import { expect } from "chai";
-import { stub } from "sinon";
-import * as React from "react";
-import { shallow, mount } from "enzyme";
-import { Button } from "library-simplified-reusable-components";
-import { DragDropContext, Draggable, Droppable } from "react-beautiful-dnd";
-
-import CustomListEntriesEditor from "../CustomListEntriesEditor";
-
-import { AudioHeadphoneIcon, BookIcon } from "@nypl/dgx-svg-icons";
-import * as PropTypes from "prop-types";
-
-describe("CustomListEntriesEditor", () => {
- let addAllEntries;
- let addEntry;
- let deleteAllEntries;
- let deleteEntry;
- let loadMoreSearchResults;
- let loadMoreEntries;
- let refreshResults;
- let childContextTypes;
- let fullContext;
-
- const searchResultsData = {
- id: "id",
- url: "url",
- title: "title",
- lanes: [],
- navigationLinks: [],
- books: [
- {
- id: "1",
- title: "result 1",
- authors: ["author 1"],
- url: "/some/url1",
- language: "eng",
- raw: {
- $: { "schema:additionalType": { value: "http://schema.org/EBook" } },
- },
- },
- {
- id: "2",
- title: "result 2",
- authors: ["author 2a", "author 2b"],
- url: "/some/url2",
- language: "eng",
- raw: {
- $: {
- "schema:additionalType": {
- value: "http://bib.schema.org/Audiobook",
- },
- },
- },
- },
- {
- id: "3",
- title: "result 3",
- authors: ["author 3"],
- url: "/some/url3",
- language: "eng",
- raw: {
- $: { "schema:additionalType": { value: "http://schema.org/EBook" } },
- },
- },
- ],
- };
-
- const entriesData = [
- {
- id: "A",
- title: "entry A",
- authors: ["author A"],
- url: "/some/urlA",
- raw: {
- $: { "schema:additionalType": { value: "http://schema.org/EBook" } },
- },
- },
- {
- id: "B",
- title: "entry B",
- authors: ["author B1", "author B2"],
- url: "/some/urlB",
- raw: {
- $: {
- "schema:additionalType": { value: "http://bib.schema.org/Audiobook" },
- },
- },
- },
- ];
-
- beforeEach(() => {
- addAllEntries = stub();
- addEntry = stub();
- deleteAllEntries = stub();
- deleteEntry = stub();
- loadMoreSearchResults = stub();
- loadMoreEntries = stub();
- refreshResults = stub();
-
- childContextTypes = {
- pathFor: PropTypes.func.isRequired,
- router: PropTypes.object.isRequired,
- };
-
- fullContext = Object.assign(
- {},
- {
- pathFor: stub().returns("url"),
- router: {
- createHref: stub(),
- push: stub(),
- isActive: stub(),
- replace: stub(),
- go: stub(),
- goBack: stub(),
- goForward: stub(),
- setRouteLeaveHook: stub(),
- },
- }
- );
- });
-
- it("renders search results", () => {
- const wrapper = mount(
- ,
- { context: fullContext, childContextTypes }
- );
-
- const resultsContainer = wrapper.find(".custom-list-search-results");
-
- expect(resultsContainer.length).to.equal(1);
-
- const droppable = resultsContainer.find(Droppable);
-
- expect(droppable.length).to.equal(1);
-
- const results = droppable.find(Draggable);
-
- expect(results.length).to.equal(3);
- expect(results.at(0).text()).to.contain("result 1");
- expect(results.at(0).text()).to.contain("author 1");
- expect(results.at(1).text()).to.contain("result 2");
- expect(results.at(1).text()).to.contain("author 2a, author 2b");
- expect(results.at(2).text()).to.contain("result 3");
- expect(results.at(2).text()).to.contain("author 3");
- });
-
- it("does not render search results when isOwner is false", () => {
- const wrapper = mount(
- ,
- { context: fullContext, childContextTypes }
- );
-
- const resultsContainer = wrapper.find(".custom-list-search-results");
-
- expect(resultsContainer.length).to.equal(0);
- });
-
- it("calls refreshResults when the refresh button is clicked", () => {
- const wrapper = mount(
- ,
- { context: fullContext, childContextTypes }
- );
-
- const refreshButton = wrapper.find(".btn.refresh-button");
-
- refreshButton.at(0).simulate("click");
-
- expect(refreshResults.callCount).to.equal(1);
- });
-
- it("disables the refresh button when isFetchingSearchResults is true", () => {
- const wrapper = mount(
- ,
- { context: fullContext, childContextTypes }
- );
-
- const refreshButton = wrapper.find(".btn.refresh-button");
-
- expect(refreshButton.prop("disabled")).to.equal(true);
- });
-
- it("disables the refresh button when isFetchingSearchResults is true", () => {
- const wrapper = mount(
- ,
- { context: fullContext, childContextTypes }
- );
-
- const refreshButton = wrapper.find(".btn.refresh-button");
-
- expect(refreshButton.prop("disabled")).to.equal(true);
- });
-
- it("shows a loading message when isFetchingSearchResults is true", () => {
- const wrapper = mount(
- ,
- { context: fullContext, childContextTypes }
- );
-
- const loadingIndicator = wrapper.find(".list-loading");
-
- expect(loadingIndicator.length).to.equal(1);
- expect(loadingIndicator.text()).to.contain("Loading");
- });
-
- it("renders a link to view each search result", () => {
- const wrapper = mount(
- ,
- { context: fullContext, childContextTypes }
- );
-
- const resultsContainer = wrapper.find(".custom-list-search-results");
-
- expect(resultsContainer.length).to.equal(1);
-
- const droppable = resultsContainer.find(Droppable);
-
- expect(droppable.length).to.equal(1);
-
- const results = droppable.find(Draggable);
-
- expect(results.length).to.equal(3);
-
- expect(results.at(0).find("CatalogLink").text()).to.equal("View details");
- expect(results.at(0).find("CatalogLink").prop("bookUrl")).to.equal(
- "/some/url1"
- );
-
- expect(results.at(1).find("CatalogLink").text()).to.equal("View details");
- expect(results.at(1).find("CatalogLink").prop("bookUrl")).to.equal(
- "/some/url2"
- );
-
- expect(results.at(2).find("CatalogLink").text()).to.equal("View details");
- expect(results.at(2).find("CatalogLink").prop("bookUrl")).to.equal(
- "/some/url3"
- );
- });
-
- it("does not include the opds feed url in links to view search results", () => {
- const wrapper = mount(
- ,
- { context: fullContext, childContextTypes }
- );
-
- const results = wrapper.find(".custom-list-search-results Draggable");
-
- expect(results.at(0).find("CatalogLink").prop("collectionUrl")).to.equal(
- undefined
- );
-
- expect(results.at(1).find("CatalogLink").prop("collectionUrl")).to.equal(
- undefined
- );
-
- expect(results.at(2).find("CatalogLink").prop("collectionUrl")).to.equal(
- undefined
- );
- });
-
- it("renders an SVG icon for each search result", () => {
- const wrapper = mount(
- ,
- { context: fullContext, childContextTypes }
- );
-
- const resultsContainer = wrapper.find(".custom-list-search-results");
- const audioSVGs = resultsContainer.find(AudioHeadphoneIcon);
- const bookSVGs = resultsContainer.find(BookIcon);
-
- expect(audioSVGs.length).to.equal(1);
- expect(bookSVGs.length).to.equal(2);
- });
-
- it("doesn't render an SVG icon for books with a bad medium value", () => {
- searchResultsData.books[2].raw["$"]["schema:additionalType"].value = "";
-
- const wrapper = mount(
- ,
- { context: fullContext, childContextTypes }
- );
-
- const resultsContainer = wrapper.find(".custom-list-search-results");
- const audioSVGs = resultsContainer.find(AudioHeadphoneIcon);
- const bookSVGs = resultsContainer.find(BookIcon);
-
- expect(audioSVGs.length).to.equal(1);
- expect(bookSVGs.length).to.equal(1);
-
- searchResultsData.books[2].raw["$"]["schema:additionalType"].value =
- "http://schema.org/EBook";
- });
-
- it("renders list entries", () => {
- const wrapper = mount(
- ,
- { context: fullContext, childContextTypes }
- );
-
- const entriesContainer = wrapper.find(".custom-list-entries");
-
- expect(entriesContainer.length).to.equal(1);
-
- const droppable = entriesContainer.find(Droppable);
-
- expect(droppable.length).to.equal(1);
-
- const entries = droppable.find(Draggable);
-
- expect(entries.length).to.equal(2);
-
- expect(entries.at(0).text()).to.contain("entry A");
- expect(entries.at(0).text()).to.contain("author A");
- expect(entries.at(1).text()).to.contain("entry B");
- expect(entries.at(1).text()).to.contain("author B1, author B2");
-
- const display = wrapper.find(".custom-list-entries h4");
-
- expect(display.text()).to.equal(
- "List Entries: Displaying 1 - 2 of 2 books"
- );
- });
-
- it("makes list entries read only if autoUpdate is true", () => {
- const wrapper = mount(
- ,
- { context: fullContext, childContextTypes }
- );
-
- const entriesContainer = wrapper.find(".custom-list-entries");
- const removeAllButton = entriesContainer.find(".droppable-header button");
-
- expect(removeAllButton.length).to.equal(0);
-
- const removeEntryButtons = entriesContainer.find(
- ".custom-list-entry button"
- );
-
- expect(removeEntryButtons.length).to.equal(0);
- });
-
- it("renders an auto update status if autoUpdate is true", () => {
- const wrapper = mount(
- ,
- { context: fullContext, childContextTypes }
- );
-
- let status;
-
- status = wrapper.find(".custom-list-entries .auto-update-status-name");
- expect(status.text()).to.equal("Status: New");
-
- wrapper.setProps({ isSearchModified: true });
-
- status = wrapper.find(".custom-list-entries .auto-update-status-name");
- expect(status.text()).to.equal("Status: New");
-
- wrapper.setProps({
- listId: "123",
- isSearchModified: false,
- autoUpdateStatus: "init",
- });
-
- status = wrapper.find(".custom-list-entries .auto-update-status-name");
- expect(status.text()).to.equal("Status: Initializing");
-
- wrapper.setProps({ isSearchModified: true });
-
- status = wrapper.find(".custom-list-entries .auto-update-status-name");
- expect(status.text()).to.equal("Status: Search criteria modified");
-
- wrapper.setProps({ isSearchModified: false, autoUpdateStatus: "updated" });
-
- status = wrapper.find(".custom-list-entries .auto-update-status-name");
- expect(status.text()).to.equal("Status: Updated");
-
- wrapper.setProps({ isSearchModified: true });
-
- status = wrapper.find(".custom-list-entries .auto-update-status-name");
- expect(status.text()).to.equal("Status: Search criteria modified");
-
- wrapper.setProps({
- isSearchModified: false,
- autoUpdateStatus: "repopulate",
- });
-
- status = wrapper.find(".custom-list-entries .auto-update-status-name");
- expect(status.text()).to.equal("Status: Repopulating");
-
- wrapper.setProps({ isSearchModified: true });
-
- status = wrapper.find(".custom-list-entries .auto-update-status-name");
- expect(status.text()).to.equal("Status: Search criteria modified");
-
- wrapper.setProps({ autoUpdate: false });
-
- status = wrapper.find(".custom-list-entries .auto-update-status-name");
-
- expect(status.length).to.equal(0);
- });
-
- it("renders a link to view each entry", () => {
- const wrapper = mount(
- ,
- { context: fullContext, childContextTypes }
- );
-
- const entriesContainer = wrapper.find(".custom-list-entries");
-
- expect(entriesContainer.length).to.equal(1);
-
- const droppable = entriesContainer.find(Droppable);
-
- expect(droppable.length).to.equal(1);
-
- const entries = droppable.find(Draggable);
-
- expect(entries.length).to.equal(2);
-
- expect(entries.at(0).find("CatalogLink").text()).to.equal("View details");
- expect(entries.at(0).find("CatalogLink").prop("bookUrl")).to.equal(
- "/some/urlA"
- );
-
- expect(entries.at(1).find("CatalogLink").text()).to.equal("View details");
- expect(entries.at(1).find("CatalogLink").prop("bookUrl")).to.equal(
- "/some/urlB"
- );
- });
-
- it("includes the opds feed url in links to view entries", () => {
- const wrapper = mount(
- ,
- { context: fullContext, childContextTypes }
- );
-
- const entries = wrapper.find(".custom-list-entries Draggable");
-
- expect(entries.at(0).find("CatalogLink").prop("collectionUrl")).to.equal(
- "opdsFeedUrl"
- );
-
- expect(entries.at(1).find("CatalogLink").prop("collectionUrl")).to.equal(
- "opdsFeedUrl"
- );
- });
-
- it("renders an SVG icon for each entry", () => {
- const wrapper = mount(
- ,
- { context: fullContext, childContextTypes }
- );
-
- const entriesContainer = wrapper.find(".custom-list-entries");
- const audioSVGs = entriesContainer.find(AudioHeadphoneIcon);
- const bookSVGs = entriesContainer.find(BookIcon);
-
- expect(audioSVGs.length).to.equal(1);
- expect(bookSVGs.length).to.equal(1);
- });
-
- it("doesn't include search results that are already in the entries list when autoUpdate is false", () => {
- const entriesData = [
- {
- id: "1",
- title: "result 1",
- authors: ["author 1"],
- language: "eng",
- raw: {
- $: {
- "schema:additionalType": {
- value: "http://bib.schema.org/Audiobook",
- },
- },
- },
- },
- ];
-
- const wrapper = mount(
- ,
- { context: fullContext, childContextTypes }
- );
-
- const resultsContainer = wrapper.find(".custom-list-search-results");
-
- expect(resultsContainer.length).to.equal(1);
-
- const droppable = resultsContainer.find(Droppable);
-
- expect(droppable.length).to.equal(1);
-
- const results = droppable.find(Draggable);
-
- expect(results.length).to.equal(2);
-
- expect(results.at(0).text()).to.contain("result 2");
- expect(results.at(0).text()).to.contain("author 2a, author 2b");
- expect(results.at(1).text()).to.contain("result 3");
- expect(results.at(1).text()).to.contain("author 3");
- });
-
- it("shows all search results, even ones that are in the entries list, when autoUpdate is true", () => {
- const entriesData = [
- {
- id: "1",
- title: "result 1",
- authors: ["author 1"],
- language: "eng",
- raw: {
- $: {
- "schema:additionalType": {
- value: "http://bib.schema.org/Audiobook",
- },
- },
- },
- },
- ];
-
- const wrapper = mount(
- ,
- { context: fullContext, childContextTypes }
- );
-
- const resultsContainer = wrapper.find(".custom-list-search-results");
-
- expect(resultsContainer.length).to.equal(1);
-
- const droppable = resultsContainer.find(Droppable);
-
- expect(droppable.length).to.equal(1);
-
- const results = droppable.find(Draggable);
-
- expect(results.length).to.equal(3);
- expect(results.at(0).text()).to.contain("result 1");
- expect(results.at(0).text()).to.contain("author 1");
- expect(results.at(1).text()).to.contain("result 2");
- expect(results.at(1).text()).to.contain("author 2a, author 2b");
- expect(results.at(2).text()).to.contain("result 3");
- expect(results.at(2).text()).to.contain("author 3");
- });
-
- // FIXME: react-beautiful-dnd does not work well in jsdom, so the following drag-and-drop tests
- // reach into the render tree to find the DragDropContext, and directly call the onDragStart and
- // onDragEnd props. A better way would be to simulate the mouse/keyboard events that occur during
- // a drag-and-drop operation, without knowing anything about the implementation. This would be
- // possible if the tests ran in real browsers, e.g. by using Karma.
-
- it("prevents dragging within search results", () => {
- const wrapper = mount(
- ,
- { context: fullContext, childContextTypes }
- );
-
- // Simulate starting a drag from search results.
-
- const dragDropContext = wrapper.find(DragDropContext);
- const onDragStart = dragDropContext.prop("onDragStart");
-
- onDragStart({
- draggableId: "1",
- source: {
- droppableId: "search-results",
- },
- });
-
- wrapper.update();
-
- const resultsContainer = wrapper.find(".custom-list-search-results");
- const resultsDroppable = resultsContainer.find(Droppable);
-
- expect(resultsDroppable.prop("isDropDisabled")).to.equal(true);
- });
-
- it("prevents dragging within list entries", () => {
- const wrapper = mount(
- ,
- { context: fullContext, childContextTypes }
- );
-
- // Simulate starting a drag from list entries.
-
- const dragDropContext = wrapper.find(DragDropContext);
- const onDragStart = dragDropContext.prop("onDragStart");
-
- onDragStart({
- draggableId: "A",
- source: {
- droppableId: "custom-list-entries",
- },
- });
-
- wrapper.update();
-
- const entriesContainer = wrapper.find(".custom-list-entries");
- const entriesDroppable = entriesContainer.find(Droppable);
-
- expect(entriesDroppable.prop("isDropDisabled")).to.equal(true);
- });
-
- it("calls addEntry when a book is dragged from search results to list entries", () => {
- const wrapper = mount(
- ,
- { context: fullContext, childContextTypes }
- );
-
- // Simulate starting a drag from search results.
-
- const dragDropContext = wrapper.find(DragDropContext);
- const onDragStart = dragDropContext.prop("onDragStart");
-
- onDragStart({
- draggableId: "1",
- source: {
- droppableId: "search-results",
- },
- });
-
- wrapper.update();
-
- const entriesContainer = wrapper.find(".custom-list-entries");
- const entriesDroppable = entriesContainer.find(Droppable);
-
- expect(entriesDroppable.prop("isDropDisabled")).to.equal(false);
-
- // Simulate dropping on the entries.
-
- const onDragEnd = dragDropContext.prop("onDragEnd");
-
- onDragEnd({
- draggableId: "1",
- source: {
- droppableId: "search-results",
- },
- destination: {
- droppableId: "custom-list-entries",
- },
- });
-
- wrapper.update();
-
- expect(addEntry.callCount).to.equal(1);
- expect(addEntry.args[0]).to.deep.equal(["1"]);
- });
-
- it("shows a message in place of search results when dragging from list entries", () => {
- const wrapper = mount(
- ,
- { context: fullContext, childContextTypes }
- );
-
- // Simulate starting a drag from list entries.
-
- const dragDropContext = wrapper.find(DragDropContext);
- const onDragStart = dragDropContext.prop("onDragStart");
-
- onDragStart({
- draggableId: "A",
- source: {
- droppableId: "custom-list-entries",
- },
- });
-
- wrapper.update();
-
- let resultsContainer = wrapper.find(".custom-list-search-results");
- let resultsDroppable = resultsContainer.find(Droppable);
-
- expect(resultsDroppable.prop("isDropDisabled")).to.equal(false);
-
- let message = resultsDroppable.find("p");
-
- expect(message.length).to.equal(1);
- expect(message.text()).to.contain("here to remove");
-
- // If the drop occurs anywhere on the page, the message goes away.
- // Simulate dropping outside a droppable (no destination).
-
- const onDragEnd = dragDropContext.prop("onDragEnd");
-
- onDragEnd({
- draggableId: "A",
- source: {
- droppableId: "custom-list-entries",
- },
- });
-
- wrapper.update();
-
- resultsContainer = wrapper.find(".custom-list-search-results");
- resultsDroppable = resultsContainer.find(Droppable);
-
- expect(resultsDroppable.prop("isDropDisabled")).to.equal(true);
-
- message = resultsDroppable.find("p");
-
- expect(message.length).to.equal(0);
- });
-
- it("calls deleteEntry when a book is dragged from list entries to search results", () => {
- const wrapper = mount(
- ,
- { context: fullContext, childContextTypes }
- );
-
- // Simulate starting a drag from list entries.
-
- const dragDropContext = wrapper.find(DragDropContext);
- const onDragStart = dragDropContext.prop("onDragStart");
-
- onDragStart({
- draggableId: "A",
- source: {
- droppableId: "custom-list-entries",
- },
- });
-
- wrapper.update();
-
- const resultsContainer = wrapper.find(".custom-list-search-results");
- const resultsDroppable = resultsContainer.find(Droppable);
-
- expect(resultsDroppable.prop("isDropDisabled")).to.equal(false);
-
- // Simulate dropping on the search results.
-
- const onDragEnd = dragDropContext.prop("onDragEnd");
-
- onDragEnd({
- draggableId: "A",
- source: {
- droppableId: "custom-list-entries",
- },
- destination: {
- droppableId: "search-results",
- },
- });
-
- wrapper.update();
-
- expect(deleteEntry.callCount).to.equal(1);
- expect(deleteEntry.args[0]).to.deep.equal(["A"]);
- });
-
- it("calls addEntry when the Add to List button is clicked on a search result", () => {
- const wrapper = mount(
- ,
- { context: fullContext, childContextTypes }
- );
-
- const display = wrapper.find(".custom-list-entries h4");
-
- expect(display.text()).to.equal(
- "List Entries: Displaying 1 - 2 of 2 books"
- );
-
- const addButton = wrapper
- .find(".custom-list-search-results .links")
- .find(Button);
-
- addButton.at(0).simulate("click");
-
- expect(addEntry.callCount).to.equal(1);
- expect(addEntry.args[0]).to.deep.equal(["1"]);
- });
-
- it("hides the Add to List buttons when autoUpdate is true", () => {
- const wrapper = mount(
- ,
- { context: fullContext, childContextTypes }
- );
-
- const addButton = wrapper
- .find(".custom-list-search-results .links")
- .find(Button);
-
- expect(addButton.length).to.equal(0);
- });
-
- it("calls deleteEntry when the Remove from List button is clicked on a list entry", () => {
- const wrapper = mount(
- ,
- { context: fullContext, childContextTypes }
- );
-
- const display = wrapper.find(".custom-list-entries h4");
-
- expect(display.text()).to.equal(
- "List Entries: Displaying 1 - 2 of 2 books"
- );
-
- const deleteButton = wrapper
- .find(".custom-list-entries .links")
- .find(Button);
-
- deleteButton.at(0).simulate("click");
-
- expect(deleteEntry.callCount).to.equal(1);
- expect(deleteEntry.args[0]).to.deep.equal(["A"]);
- });
-
- it("does not render Remove from List buttons on a list entries when isOwner is false", () => {
- const wrapper = mount(
- ,
- { context: fullContext, childContextTypes }
- );
-
- const deleteButtons = wrapper.find(".custom-list-entries .links button");
-
- expect(deleteButtons.length).to.equal(0);
- });
-
- it("does not render the Add All to List button when there are no search results", () => {
- const wrapper = shallow(
- ,
- { context: fullContext, childContextTypes }
- );
-
- const button = wrapper.find(".add-all-button");
-
- expect(button.length).to.equal(0);
- });
-
- it("does not render the Add All to List button when autoUpdate is true", () => {
- const wrapper = shallow(
- ,
- { context: fullContext, childContextTypes }
- );
-
- const button = wrapper.find(".add-all-button");
-
- expect(button.length).to.equal(0);
- });
-
- it("does not render the Add All to List button when isOwner is false", () => {
- const wrapper = shallow(
- ,
- { context: fullContext, childContextTypes }
- );
-
- const button = wrapper.find(".add-all-button");
-
- expect(button.length).to.equal(0);
- });
-
- it("calls addAllEntries when the Add All to List button is clicked", () => {
- const wrapper = mount(
- ,
- { context: fullContext, childContextTypes }
- );
-
- const display = wrapper.find(".custom-list-entries h4");
-
- expect(display.text()).to.equal(
- "List Entries: Displaying 1 - 2 of 2 books"
- );
-
- const button = wrapper.find(".add-all-button").at(0);
-
- button.simulate("click");
-
- expect(addAllEntries.callCount).to.equal(1);
- });
-
- it("does not render the Delete all button when there are no entries", () => {
- const wrapper = shallow(
- ,
- { context: fullContext, childContextTypes }
- );
-
- const button = wrapper.find(".delete-all-button");
-
- expect(button.length).to.equal(0);
- });
-
- it("does not render the Delete all button when isOwner is false", () => {
- const wrapper = shallow(
- ,
- { context: fullContext, childContextTypes }
- );
-
- const button = wrapper.find(".delete-all-button");
-
- expect(button.length).to.equal(0);
- });
-
- it("calls deleteAllEntries when the Delete all button is clicked", () => {
- const wrapper = mount(
- ,
- { context: fullContext, childContextTypes }
- );
-
- const display = wrapper.find(".custom-list-entries h4");
-
- expect(display.text()).to.equal(
- "List Entries: Displaying 1 - 2 of 2 books"
- );
-
- const button = wrapper.find(".delete-all-button").at(0);
-
- button.simulate("click");
-
- expect(deleteAllEntries.callCount).to.equal(1);
- });
-
- it("hides the Load More button in search results when loadMoreSearchResults is null", () => {
- const wrapper = mount(
- ,
- { context: fullContext, childContextTypes }
- );
-
- const button = wrapper.find(
- ".custom-list-search-results .load-more-button"
- );
-
- expect(button.length).to.equal(0);
- });
-
- it("hides the Load More button in search results when isFetchingSearchResults is true", () => {
- const wrapper = mount(
- ,
- { context: fullContext, childContextTypes }
- );
-
- const button = wrapper.find(
- ".custom-list-search-results .load-more-button"
- );
-
- expect(button.length).to.equal(0);
- });
-
- it("hides Load More button in list entries when loadMoreEntries is null", () => {
- const wrapper = mount(
- ,
- { context: fullContext, childContextTypes }
- );
-
- const button = wrapper.find(".custom-list-entries .load-more-button");
-
- expect(button.length).to.equal(0);
- });
-
- it("disables the Load More button when loading more search results", () => {
- const wrapper = mount(
- ,
- { context: fullContext, childContextTypes }
- );
-
- let button = wrapper
- .find(".custom-list-search-results .load-more-button")
- .hostNodes();
-
- expect(button.length).to.equal(1);
- expect(button.prop("disabled")).not.to.be.true;
-
- wrapper.setProps({ isFetchingMoreSearchResults: true });
-
- button = wrapper
- .find(".custom-list-search-results .load-more-button")
- .hostNodes();
-
- expect(button.length).to.equal(1);
- expect(button.prop("disabled")).to.equal(true);
- });
-
- it("disables the Load More button when loading more list entries", () => {
- const wrapper = mount(
- ,
- { context: fullContext, childContextTypes }
- );
-
- let button = wrapper
- .find(".custom-list-entries .load-more-button")
- .hostNodes();
-
- expect(button.length).to.equal(1);
- expect(button.prop("disabled")).not.to.be.true;
-
- wrapper.setProps({ isFetchingMoreCustomListEntries: true });
-
- button = wrapper.find(".custom-list-entries .load-more-button").hostNodes();
-
- expect(button.length).to.equal(1);
- expect(button.prop("disabled")).to.equal(true);
- });
-
- it("calls loadMoreSearchResults when the Load More button is clicked in search results", () => {
- const wrapper = mount(
- ,
- { context: fullContext, childContextTypes }
- );
-
- const button = wrapper
- .find(".custom-list-search-results .load-more-button")
- .at(0);
-
- button.simulate("click");
-
- expect(loadMoreSearchResults.callCount).to.equal(1);
- });
-
- it("calls loadMoreEntries when the Load More button is clicked in list entries", () => {
- const wrapper = mount(
- ,
- { context: fullContext, childContextTypes }
- );
-
- const button = wrapper.find(".custom-list-entries .load-more-button").at(0);
-
- button.simulate("click");
-
- expect(loadMoreEntries.callCount).to.equal(1);
- });
-
- it("should properly display the count of list entries", () => {
- const wrapper = mount(
- ,
- { context: fullContext, childContextTypes }
- );
-
- const display = wrapper.find(".custom-list-entries h4");
-
- expect(display.text()).to.equal(
- "List Entries: Displaying 1 - 2 of 2 books"
- );
-
- wrapper.setProps({
- entries: entriesData.slice(0, 1),
- entryCount: 1,
- });
-
- expect(display.text()).to.equal("List Entries: Displaying 1 - 1 of 1 book");
-
- wrapper.setProps({
- entries: [],
- entryCount: 0,
- });
-
- expect(display.text()).to.equal("List Entries: No books in this list");
-
- wrapper.setProps({
- entryCount: 12,
- });
-
- expect(display.text()).to.equal(
- "List Entries: Displaying 0 - 0 of 12 books"
- );
-
- wrapper.setProps({
- entries: entriesData,
- });
-
- expect(display.text()).to.equal(
- "List Entries: Displaying 1 - 2 of 12 books"
- );
- });
-});
diff --git a/src/components/__tests__/CustomLists-test.tsx b/src/components/__tests__/CustomLists-test.tsx
deleted file mode 100644
index 6763af875b..0000000000
--- a/src/components/__tests__/CustomLists-test.tsx
+++ /dev/null
@@ -1,805 +0,0 @@
-import { expect } from "chai";
-import { stub } from "sinon";
-
-import * as React from "react";
-import { shallow, mount } from "enzyme";
-
-import { CustomLists } from "../CustomLists";
-import ErrorMessage from "../ErrorMessage";
-import LoadingIndicator from "@thepalaceproject/web-opds-client/lib/components/LoadingIndicator";
-import CustomListEditor from "../CustomListEditor";
-import Admin from "../../models/Admin";
-import { LaneData } from "../../interfaces";
-import CustomListsSidebar from "../CustomListsSidebar";
-import * as navigate from "../../utils/navigate";
-
-describe("CustomLists", () => {
- let wrapper;
- let fetchCustomLists;
- let fetchCustomListDetails;
- let deleteCustomList;
- let openCustomListEditor;
- let saveCustomListEditor;
- let executeCustomListEditorSearch;
- let loadMoreSearchResults;
- let loadMoreEntries;
- let fetchCollections;
- let fetchLibraries;
- let fetchLanes;
- let fetchLanguages;
-
- const customListEditorProperties = {
- name: "",
- collections: [],
- autoUpdate: false,
- };
-
- const customListEditorEntries = {
- baseline: [],
- baselineTotalCount: 0,
- added: {},
- removed: {},
- current: [],
- currentTotalCount: 0,
- };
-
- const customListEditorSearchParams = {
- entryPoint: "All",
- terms: "",
- sort: null,
- language: "all",
- advanced: {
- include: {
- query: null,
- selectedQueryId: null,
- clearFilters: null,
- },
- exclude: {
- query: null,
- selectedQueryId: null,
- clearFilters: null,
- },
- },
- };
-
- const listsData = [
- {
- id: 1,
- name: "a list",
- entry_count: 0,
- collections: [],
- is_owner: true,
- is_shared: false,
- },
- {
- id: 2,
- name: "z list",
- entry_count: 1,
- collections: [{ id: 3, name: "collection 3", protocol: "protocol" }],
- is_owner: true,
- is_shared: false,
- },
- ];
-
- const entry = { pwid: "1", title: "title", authors: [] };
-
- const searchResults = {
- id: "id",
- url: "url",
- title: "title",
- lanes: [],
- books: [],
- navigationLinks: [],
- nextPageUrl: "http://next.page",
- };
-
- const collections = [
- {
- id: 1,
- name: "collection 1",
- protocol: "protocol",
- libraries: [{ short_name: "other library" }],
- },
- {
- id: 2,
- name: "collection 2",
- protocol: "protocol",
- libraries: [{ short_name: "library" }],
- },
- {
- id: 3,
- name: "collection 3",
- protocol: "protocol",
- libraries: [{ short_name: "library" }],
- },
- ];
-
- const libraries = [
- {
- short_name: "library",
- settings: {
- enabled_entry_points: ["Book", "Audio"],
- },
- },
- {
- short_name: "another library",
- settings: {
- enabled_entry_points: ["Audio"],
- },
- },
- ];
-
- const languages = {
- eng: ["English"],
- spa: ["Spanish", "Castilian"],
- fre: ["French"],
- };
-
- const lane1: LaneData = {
- id: 1,
- display_name: "lane 1",
- visible: false,
- count: 1,
- sublanes: [],
- custom_list_ids: [2],
- inherit_parent_restrictions: false,
- };
- const lane2: LaneData = {
- id: 2,
- display_name: "lane 2",
- visible: false,
- count: 1,
- sublanes: [],
- custom_list_ids: [2],
- inherit_parent_restrictions: false,
- };
- const lane3: LaneData = {
- id: 3,
- display_name: "lane 2",
- visible: false,
- count: 1,
- sublanes: [],
- custom_list_ids: [],
- inherit_parent_restrictions: false,
- };
- const allLanes = [lane1, lane2, lane3];
- const lanesToDelete = [lane1, lane2];
-
- const libraryManager = new Admin([{ role: "manager", library: "library" }]);
- const librarian = new Admin([{ role: "librarian", library: "library" }]);
-
- describe("on mount", () => {
- const listsUrl = "/admin/web/lists/library";
- let navigateTo;
- let currentHref;
- const lastNavigation = () => navigateTo.args[navigateTo.callCount - 1][0];
-
- beforeEach(() => {
- navigateTo = stub(navigate, "navigateTo");
- currentHref = stub(navigate, "currentHref").returns(listsUrl);
-
- fetchCustomLists = stub();
- openCustomListEditor = stub();
- fetchCustomListDetails = stub();
- saveCustomListEditor = stub().returns(Promise.resolve());
- deleteCustomList = stub().returns(Promise.resolve());
- executeCustomListEditorSearch = stub();
- loadMoreSearchResults = stub();
- loadMoreEntries = stub();
- fetchCollections = stub();
- fetchLibraries = stub();
- fetchLanes = stub().returns(Promise.resolve());
- fetchLanguages = stub();
-
- wrapper = mount(
- ,
- { context: { admin: libraryManager } }
- );
- });
-
- afterEach(() => {
- navigateTo.restore();
- currentHref.restore();
- });
-
- it("fetches libraries and languages", () => {
- expect(fetchLibraries.callCount).to.equal(1);
- expect(fetchLanguages.callCount).to.equal(1);
- });
-
- it("renders error message", () => {
- let error = wrapper.find(ErrorMessage);
- expect(error.length).to.equal(0);
-
- wrapper.setProps({
- fetchError: { status: 500, response: "Error", url: "url" },
- });
- error = wrapper.find(ErrorMessage);
- expect(error.length).to.equal(1);
- });
-
- it("renders loading message", () => {
- let loading = wrapper.find(LoadingIndicator);
- expect(loading.length).to.equal(0);
-
- wrapper.setProps({ isFetching: true });
- loading = wrapper.find(LoadingIndicator);
- expect(loading.length).to.equal(1);
- });
-
- it("navigates to create or edit page on initial load", () => {
- wrapper = mount(
- ,
- { context: { admin: libraryManager } }
- );
- wrapper.setProps({ lists: [] });
- expect(lastNavigation()).to.equal(`${listsUrl}/create`);
-
- wrapper = mount(
- ,
- { context: { admin: libraryManager } }
- );
- wrapper.setProps({ lists: listsData });
- expect(lastNavigation()).to.equal(`${listsUrl}/edit/1`);
-
- // The create page should open if there are no owned lists.
-
- const noOwnedListsData = [
- {
- id: 1,
- name: "a list",
- entry_count: 0,
- collections: [],
- is_owner: false,
- is_shared: true,
- },
- ];
-
- wrapper = mount(
- ,
- { context: { admin: libraryManager } }
- );
- wrapper.setProps({ lists: noOwnedListsData });
- expect(lastNavigation()).to.equal(`${listsUrl}/create`);
- });
-
- it("sorts lists", () => {
- wrapper = mount(
- ,
- { context: { admin: libraryManager } }
- );
-
- let sidebar;
-
- sidebar = wrapper.find(CustomListsSidebar);
-
- expect(sidebar.prop("lists").map((list) => list.name)).to.deep.equal([
- "a list",
- "z list",
- ]);
-
- sidebar.invoke("changeSort")("desc");
-
- sidebar = wrapper.find(CustomListsSidebar);
-
- expect(sidebar.prop("lists").map((list) => list.name)).to.deep.equal([
- "z list",
- "a list",
- ]);
-
- sidebar.invoke("changeSort")("asc");
-
- sidebar = wrapper.find(CustomListsSidebar);
-
- expect(sidebar.prop("lists").map((list) => list.name)).to.deep.equal([
- "a list",
- "z list",
- ]);
- });
-
- it("filters lists", () => {
- const sharedListsData = [
- {
- id: 1,
- name: "owned unshared",
- entry_count: 0,
- collections: [],
- is_owner: true,
- is_shared: false,
- },
- {
- id: 2,
- name: "owned shared",
- entry_count: 1,
- collections: [{ id: 3, name: "collection 3", protocol: "protocol" }],
- is_owner: true,
- is_shared: true,
- },
- {
- id: 3,
- name: "not owned",
- entry_count: 1,
- collections: [{ id: 3, name: "collection 3", protocol: "protocol" }],
- is_owner: false,
- is_shared: true,
- },
- ];
-
- wrapper = mount(
- ,
- { context: { admin: libraryManager } }
- );
-
- let sidebar;
-
- sidebar = wrapper.find(CustomListsSidebar);
-
- expect(sidebar.prop("lists").map((list) => list.name)).to.deep.equal([
- "owned shared",
- "owned unshared",
- ]);
-
- sidebar.invoke("changeFilter")("shared-in");
-
- sidebar = wrapper.find(CustomListsSidebar);
-
- expect(sidebar.prop("lists").map((list) => list.name)).to.deep.equal([
- "not owned",
- ]);
-
- sidebar.invoke("changeFilter")("shared-out");
-
- sidebar = wrapper.find(CustomListsSidebar);
-
- expect(sidebar.prop("lists").map((list) => list.name)).to.deep.equal([
- "owned shared",
- ]);
-
- sidebar.invoke("changeFilter")("");
-
- sidebar = wrapper.find(CustomListsSidebar);
-
- expect(sidebar.prop("lists").map((list) => list.name)).to.deep.equal([
- "not owned",
- "owned shared",
- "owned unshared",
- ]);
-
- sidebar.invoke("changeFilter")("owned");
-
- sidebar = wrapper.find(CustomListsSidebar);
-
- expect(sidebar.prop("lists").map((list) => list.name)).to.deep.equal([
- "owned shared",
- "owned unshared",
- ]);
- });
-
- it("renders edit link but does not render delete button for librarian", () => {
- wrapper = mount(
- ,
- { context: { admin: librarian } }
- );
- const lists = wrapper.find("li");
- expect(lists.length).to.equal(2);
-
- const listAButtons = lists.at(0).find(".custom-list-buttons");
- const listAEditLink = listAButtons.find("Link");
- const listZButtons = lists.at(1).find(".custom-list-buttons");
- const listZEditLink = listZButtons.find("Link");
- expect(listAEditLink.length).to.equal(1);
- expect(listAEditLink.text()).to.include("Edit");
- expect(listAEditLink.prop("to")).to.equal(
- "/admin/web/lists/library/edit/1"
- );
- expect(listZEditLink.length).to.equal(1);
- expect(listZEditLink.text()).to.include("Edit");
- expect(listZEditLink.prop("to")).to.equal(
- "/admin/web/lists/library/edit/2"
- );
-
- const listADeleteButton = listAButtons.find("button");
- const listZDeleteButton = listZButtons.find("button");
- expect(listADeleteButton.length).to.equal(0);
- expect(listZDeleteButton.length).to.equal(0);
- });
-
- it("fetches lanes to be deleted", async () => {
- let deletedLanes = await (
- wrapper.instance() as CustomLists
- ).getDeletedLanes(listsData[1].id);
- // There are no lanes so fetch them.
- expect(fetchLanes.callCount).to.equal(1);
-
- // But now manually setting the lanes so the fetchLanes
- // call count should still remain at 1.
- wrapper.setProps({ lanes: allLanes });
-
- deletedLanes = await (wrapper.instance() as CustomLists).getDeletedLanes(
- listsData[1].id
- );
-
- expect(fetchLanes.callCount).to.equal(1);
- expect(deletedLanes.length).to.equal(2);
- expect(deletedLanes[0]).to.equal(lane1);
- expect(deletedLanes[1]).to.equal(lane2);
- });
-
- it("outputs a list of lanes that will be deleted when a list is deleted", () => {
- const prompt = (wrapper.instance() as CustomLists).deletedLaneNames(
- lanesToDelete
- );
- expect(prompt).to.equal(
- "Deleting this list will delete the following lanes:\n" +
- "\nLane name: lane 1\nLane name: lane 2"
- );
- });
-
- it("saves a list", async () => {
- (wrapper.instance() as CustomLists).saveCustomListEditor();
- expect(saveCustomListEditor.callCount).to.equal(1);
- });
-
- it("renders create form", async () => {
- let editor = wrapper.find(CustomListEditor);
- expect(editor.length).to.equal(0);
-
- wrapper.setProps({ editOrCreate: "create" });
- editor = wrapper.find(CustomListEditor);
- expect(editor.length).to.equal(1);
- expect(editor.props().library).to.eql(libraries[0]);
- expect(editor.props().list).to.be.undefined;
- expect(editor.props().search).to.equal(executeCustomListEditorSearch);
- expect(editor.props().loadMoreSearchResults).to.equal(
- loadMoreSearchResults
- );
- expect(editor.props().searchResults).to.equal(searchResults);
- expect(editor.props().isFetchingMoreSearchResults).to.equal(false);
- expect(editor.props().collections).to.deep.equal([
- collections[1],
- collections[2],
- ]);
- expect(editor.props().languages).to.eql(languages);
-
- expect(fetchCustomLists.callCount).to.equal(1);
- const save = editor.props().save;
- await save();
- expect(saveCustomListEditor.callCount).to.equal(1);
- expect(fetchCustomLists.callCount).to.equal(2);
- });
-
- it("renders edit form", () => {
- let editor = wrapper.find(CustomListEditor);
- expect(editor.length).to.equal(0);
-
- const listDetails = Object.assign({}, listsData[1], { entries: [entry] });
- wrapper.setProps({ editOrCreate: "edit", identifier: "2", listDetails });
- editor = wrapper.find(CustomListEditor);
- expect(editor.length).to.equal(1);
- expect(editor.props().library).to.eql(libraries[0]);
- expect(editor.props().search).to.equal(executeCustomListEditorSearch);
- expect(editor.props().loadMoreSearchResults).to.equal(
- loadMoreSearchResults
- );
- expect(editor.props().searchResults).to.equal(searchResults);
- expect(editor.props().isFetchingMoreSearchResults).to.equal(false);
- expect(editor.props().collections).to.deep.equal([
- collections[1],
- collections[2],
- ]);
- expect(editor.props().languages).to.eql(languages);
-
- expect(fetchCustomListDetails.callCount).to.equal(1);
-
- // When the component switches to a different list, it fetches the new
- // list details.
- wrapper.setProps({ identifier: "1" });
- expect(fetchCustomListDetails.callCount).to.equal(2);
- });
-
- it("gets the correct entry points list from the right library", () => {
- const entryPoints = wrapper.instance().getEnabledEntryPoints(libraries);
-
- expect(entryPoints.length).to.equal(2);
- expect(entryPoints).to.eql(["Book", "Audio"]);
- });
-
- it("gets the correct entry points list from the second available library", () => {
- wrapper = mount(
- ,
- { context: { admin: librarian } }
- );
-
- const entryPoints = wrapper.instance().getEnabledEntryPoints(libraries);
-
- expect(entryPoints.length).to.equal(1);
- expect(entryPoints).to.eql(["Audio"]);
- });
- });
-
- // These tests are for mocking the confirm function in the window object.
- // It reliably works when shallow mounting a component but not when doing a
- // full mount. Seems to be a problem with Node v10+.
- describe("on shallow mount", () => {
- let confirmStub;
- let listDataSort;
- let deleteCustomListFn;
-
- beforeEach(() => {
- confirmStub = stub(window, "confirm");
- fetchCustomLists = stub();
- openCustomListEditor = stub();
- fetchCustomListDetails = stub();
- saveCustomListEditor = stub().returns(Promise.resolve());
- deleteCustomList = stub().returns(Promise.resolve());
- executeCustomListEditorSearch = stub();
- loadMoreSearchResults = stub();
- loadMoreEntries = stub();
- fetchCollections = stub();
- fetchLibraries = stub();
- fetchLanes = stub().returns(Promise.resolve());
-
- wrapper = shallow(
- ,
- { context: { admin: librarian } }
- );
-
- deleteCustomListFn = (wrapper.instance() as CustomLists).deleteCustomList;
- listDataSort = (wrapper.instance() as CustomLists).filteredSortedLists(
- listsData
- );
- });
-
- afterEach(() => {
- confirmStub.restore();
- });
-
- // Ideally, the test would click on the button that calls the
- // `deleteCustomList` function which in turn calls the `deleteCustomList`
- // prop. Instead, call the instance's `deleteCustomList` function directly,
- // and check if the prop was called and with what arguments.
- it("deletes a list", async () => {
- confirmStub.returns(false);
- const getDeletedLanes = stub(
- wrapper.instance() as CustomLists,
- "getDeletedLanes"
- ).returns(Promise.resolve());
-
- // The instance's `deleteCustomList` function needs a CustomListData
- // list which is one sorted object from full list of lists. The first
- // list corresponds to the "button".
- await deleteCustomListFn(listDataSort[0]);
- expect(deleteCustomList.callCount).to.equal(0);
-
- confirmStub.returns(true);
-
- await deleteCustomListFn(listDataSort[0]);
- expect(deleteCustomList.callCount).to.equal(1);
- expect(deleteCustomList.args[0][0]).to.equal("1");
-
- getDeletedLanes.restore();
- });
-
- it("deletes a list and warns of lanes that will be deleted", async () => {
- wrapper.setProps({ lanes: allLanes });
-
- confirmStub.returns(true);
-
- await deleteCustomListFn(listDataSort[0]);
- // prettier-ignore
- expect(confirmStub.args[0][0]).to.equal("Delete list \"a list\"? ");
-
- // Continuining from the previous test, this second list corresponds
- // to the second "button" that is being clicked.
- await deleteCustomListFn(listDataSort[1]);
- // prettier-ignore
- expect(confirmStub.args[1][0]).to.equal("Delete list \"z list\"? " +
- "Deleting this list will delete the following lanes:\n" +
- "\nLane name: lane 1\nLane name: lane 2"
- );
- });
- });
-});
diff --git a/src/components/__tests__/InputList-test.tsx b/src/components/__tests__/InputList-test.tsx
deleted file mode 100644
index 1b1168b374..0000000000
--- a/src/components/__tests__/InputList-test.tsx
+++ /dev/null
@@ -1,509 +0,0 @@
-import { expect } from "chai";
-import * as React from "react";
-import { mount } from "enzyme";
-import { stub, spy } from "sinon";
-import { Button } from "library-simplified-reusable-components";
-import buildStore from "../../store";
-
-import InputList from "../InputList";
-import ProtocolFormField from "../ProtocolFormField";
-import EditableInput from "../EditableInput";
-import WithRemoveButton from "../WithRemoveButton";
-import ToolTip from "../ToolTip";
-import LanguageField from "../LanguageField";
-
-describe("InputList", () => {
- let wrapper;
- let store;
- let context;
- const value = ["Thing 1", "Thing 2"];
- const setting = {
- key: "setting",
- label: "label",
- description: "description",
- type: "list",
- };
- let parent;
- let createEditableInput;
- let labelAndDescription;
-
- beforeEach(() => {
- store = buildStore();
- context = { editorStore: store };
- parent = mount();
- createEditableInput = spy(parent.instance(), "createEditableInput");
- labelAndDescription = spy(parent.instance(), "labelAndDescription");
-
- wrapper = mount(
- ,
- { context }
- );
- });
-
- it("renders a label and description", () => {
- expect(labelAndDescription.callCount).to.equal(1);
- expect(labelAndDescription.args[0][0]).to.deep.equal(setting);
- const label = wrapper.find("label");
- expect(label.length).to.equal(1);
- expect(label.text()).to.equal("label");
-
- const description = wrapper.find(".description").at(0);
- expect(description.text()).to.equal("description");
- });
-
- it("renders a list of items", () => {
- const inputs = wrapper.find(".form-control");
- expect(inputs.length).to.equal(3);
- expect(inputs.at(0).prop("value")).to.equal("Thing 1");
- expect(inputs.at(0).prop("type")).to.equal("text");
- expect(inputs.at(0).prop("name")).to.equal("setting");
-
- expect(inputs.at(1).prop("value")).to.equal("Thing 2");
- expect(inputs.at(1).prop("type")).to.equal("text");
- expect(inputs.at(1).prop("name")).to.equal("setting");
-
- expect(inputs.at(2).prop("value")).to.equal("");
- expect(inputs.at(2).prop("type")).to.equal("text");
- expect(inputs.at(2).prop("name")).to.equal("setting");
-
- expect(createEditableInput.callCount).to.equal(3);
- });
-
- it("optionally renders links", () => {
- const urlBase = (itemName) => {
- return `admin/web/${itemName}`;
- };
- const settingWithUrlBase = { ...setting, ...{ urlBase } };
- wrapper.setProps({ setting: settingWithUrlBase });
- const links = wrapper.find("a");
- expect(links.length).to.equal(2);
- links.forEach((l, idx) => {
- expect(l.text()).to.equal(`Thing ${idx + 1}`);
- expect(l.prop("href")).to.equal(`admin/web/Thing ${idx + 1}`);
- });
- });
-
- it("renders WithRemoveButton elements", () => {
- const withRemoveButtons = wrapper.find(WithRemoveButton);
- expect(withRemoveButtons.length).to.equal(2);
- expect(withRemoveButtons.at(0).prop("disabled")).to.be.false;
- expect(withRemoveButtons.at(0).find("input").prop("value")).to.equal(
- "Thing 1"
- );
- expect(withRemoveButtons.at(1).prop("disabled")).to.be.false;
- expect(withRemoveButtons.at(1).find("input").prop("value")).to.equal(
- "Thing 2"
- );
- });
-
- it("optionally disables the remove buttons", () => {
- wrapper.setProps({ disableButton: true });
- const withRemoveButtons = wrapper.find(WithRemoveButton);
- expect(withRemoveButtons.length).to.equal(2);
- expect(withRemoveButtons.at(0).prop("disabled")).to.be.true;
- expect(withRemoveButtons.at(0).find("button").prop("disabled")).to.be.true;
- expect(withRemoveButtons.at(1).prop("disabled")).to.be.true;
- expect(withRemoveButtons.at(1).find("button").prop("disabled")).to.be.true;
- });
-
- it("renders a button for adding a list item", () => {
- const addListItemContainer = wrapper.find(".add-list-item-container");
- const addListItem = addListItemContainer.find("span.add-list-item");
- expect(addListItem.length).to.equal(1);
- expect(addListItem.find("input").prop("value")).to.equal("");
- expect(addListItemContainer.find(WithRemoveButton).length).to.equal(0);
- expect(addListItemContainer.find("button.add-list-item").text()).to.equal(
- "Add"
- );
- });
-
- it("optionally renders a geographic tooltip with extra content", () => {
- const valueWithObject = [{ "Thing 3": "extra information!" }];
- const geographicSetting = { ...setting, ...{ format: "geographic" } };
- const spyToolTip = spy(wrapper.instance(), "renderToolTip");
-
- wrapper.setProps({ setting: geographicSetting, value: valueWithObject });
-
- const withAddOn = wrapper.find(".with-add-on");
- expect(withAddOn.length).to.equal(1);
-
- const addOn = withAddOn.find(".input-group-addon");
- expect(addOn.length).to.equal(1);
- const toolTipElement = addOn.find(ToolTip);
- expect(toolTipElement.length).to.equal(1);
- expect(toolTipElement.find("svg").hasClass("locatorIcon")).to.be.true;
- expect(toolTipElement.find(".tool-tip").hasClass("point-right")).to.be.true;
- expect(toolTipElement.find(".tool-tip").text()).to.equal(
- "extra information!"
- );
-
- expect(withAddOn.find("input").length).to.equal(1);
- expect(withAddOn.find("input").prop("value")).to.equal("Thing 3");
- expect(spyToolTip.args[0]).to.eql([valueWithObject[0], "geographic"]);
- });
-
- it("renders an autocomplete field for languages", () => {
- const valueWithObject = ["abc"];
- const languageSetting = { ...setting, ...{ format: "language-code" } };
- const languages = {
- eng: ["English"],
- spa: ["Spanish", "Castilian"],
- };
- wrapper.setProps({
- setting: languageSetting,
- value: valueWithObject,
- additionalData: languages,
- });
- const languageField = wrapper.find(LanguageField);
- expect(languageField.length).to.equal(2);
-
- expect(languageField.at(0).prop("value")).to.equal("abc");
- expect(languageField.at(0).prop("name")).to.equal("setting");
- expect(languageField.at(0).prop("languages")).to.equal(languages);
-
- expect(languageField.at(1).prop("value")).to.be.undefined;
- expect(languageField.at(1).prop("name")).to.equal("setting");
- expect(languageField.at(1).prop("languages")).to.equal(languages);
- });
-
- it("removes an item", () => {
- let removables = wrapper.find(WithRemoveButton);
- expect(removables.length).to.equal(2);
- const button = removables.at(0).find(".remove-btn").hostNodes();
- button.simulate("click");
- removables = wrapper.find(WithRemoveButton);
- expect(removables.length).to.equal(1);
- });
-
- it("adds a regular item", async () => {
- let removables = wrapper.find(WithRemoveButton);
- expect(removables.length).to.equal(2);
-
- let blankInput = wrapper.find("span.add-list-item input");
- blankInput.getDOMNode().value = "Another thing...";
- blankInput.simulate("change");
- const addButton = wrapper.find("button.add-list-item");
- addButton.simulate("click");
- await new Promise((resolve) => setTimeout(resolve, 0));
-
- wrapper.update();
- removables = wrapper.find(WithRemoveButton);
- expect(removables.length).to.equal(3);
-
- blankInput = wrapper.find("span.add-list-item input");
- expect(blankInput.prop("value")).to.equal("");
- });
-
- it("adds and capitalizes an item", async () => {
- wrapper.setProps({
- setting: { ...wrapper.prop("setting"), ...{ capitalize: true } },
- });
- let removables = wrapper.find(WithRemoveButton);
- expect(removables.length).to.equal(2);
-
- let blankInput = wrapper.find("span.add-list-item input");
- blankInput.getDOMNode().value = "new york";
- blankInput.simulate("change");
- const addButton = wrapper.find("button.add-list-item");
- addButton.simulate("click");
- await new Promise((resolve) => setTimeout(resolve, 0));
-
- wrapper.update();
- removables = wrapper.find(WithRemoveButton);
- expect(removables.length).to.equal(3);
- expect(removables.at(2).find(EditableInput).prop("value")).to.equal(
- "New York"
- );
- expect(wrapper.state()["listItems"][2]).to.equal("New York");
-
- blankInput = wrapper.find("span.add-list-item input");
- expect(blankInput.prop("value")).to.equal("");
- });
-
- it("capitalizes a string", () => {
- wrapper.setProps({
- setting: { ...wrapper.prop("setting"), ...{ format: "geographic" } },
- });
- const cap = wrapper.instance().capitalize;
- // Capitalizes one word
- expect(cap("california")).to.equal("California");
- // Capitalizes multiple words
- expect(cap("new jersey")).to.equal("New Jersey");
- // Capitalizes two-letter state/province abbreviations
- expect(cap("fl")).to.equal("FL");
- // Handles mixed words and abbreviations
- expect(cap("new york city, ny")).to.equal("New York City, NY");
- });
-
- it("adds an autocompleted item", async () => {
- const languageSetting = { ...setting, format: "language-code" };
- wrapper.setProps({ setting: languageSetting, value: ["abc"] });
-
- let removables = wrapper.find(WithRemoveButton);
- expect(removables.length).to.equal(1);
- let autocomplete = wrapper
- .find("input[list='setting-autocomplete-list']")
- .at(1);
- autocomplete.getDOMNode().value = "Another language";
- autocomplete.simulate("change");
- const addButton = wrapper.find("button.add-list-item");
- addButton.simulate("click");
- await new Promise((resolve) => setTimeout(resolve, 0));
- wrapper.update();
-
- removables = wrapper.find(WithRemoveButton);
- expect(removables.length).to.equal(2);
- autocomplete = wrapper
- .find("input[list='setting-autocomplete-list']")
- .at(2);
- expect(autocomplete.prop("value")).to.equal("");
- });
-
- it("does not add an empty input item", () => {
- let removables = wrapper.find(WithRemoveButton);
- expect(removables.length).to.equal(2);
- expect(wrapper.state()["newItem"]).to.equal("");
-
- let addButton = wrapper.find("button.add-list-item");
- expect(addButton.prop("disabled")).to.be.true;
- addButton.simulate("click");
- removables = wrapper.find(WithRemoveButton);
- expect(removables.length).to.equal(2);
-
- const empty = wrapper.find("span.add-list-item input");
- empty.getDOMNode().value = "something new";
- empty.simulate("change");
- addButton = wrapper.find("button.add-list-item");
- expect(wrapper.state()["newItem"]).to.equal("something new");
- expect(addButton.prop("disabled")).not.to.be.true;
- addButton.simulate("click");
-
- removables = wrapper.find(WithRemoveButton);
- addButton = wrapper.find("button.add-list-item");
- expect(removables.length).to.equal(3);
- expect(addButton.prop("disabled")).to.be.true;
- });
-
- it("optionally accepts an onChange prop", async () => {
- const onChange = stub();
- wrapper.setProps({ onChange });
- expect(onChange.callCount).to.equal(0);
- wrapper.instance().addListItem();
- await new Promise((resolve) => setTimeout(resolve, 0));
- expect(onChange.callCount).to.equal(1);
- expect(onChange.args[0][0]).to.equal(wrapper.state());
- wrapper.instance().removeListItem("test");
- await new Promise((resolve) => setTimeout(resolve, 0));
- expect(onChange.callCount).to.equal(2);
- expect(onChange.args[1][0]).to.equal(wrapper.state());
- });
-
- it("optionally accepts a readOnly prop", () => {
- wrapper.setProps({ readOnly: true });
- expect(wrapper.find(EditableInput).at(0).prop("readOnly")).to.be.true;
- expect(wrapper.find(EditableInput).at(1).prop("readOnly")).to.be.true;
- });
-
- it("gets the value", () => {
- expect((wrapper.instance() as InputList).getValue()).to.eql(value);
- });
-
- it("clears all the items", () => {
- let removables = wrapper.find(WithRemoveButton);
- expect(removables.length).to.equal(2);
-
- (wrapper.instance() as InputList).clear();
- wrapper.update();
- removables = wrapper.find(WithRemoveButton);
- expect(removables.length).to.equal(0);
- });
- describe("dropdown menu", () => {
- let options;
- beforeEach(() => {
- options = [];
- while (options.length < 3) {
- const optionName = `Option ${options.length + 1}`;
- options.push(
-
- );
- }
- const menuSetting = {
- ...setting,
- ...{ type: "menu", menuOptions: options, description: null },
- };
- wrapper = mount(
- ,
- { context }
- );
- });
- it("renders a dropdown menu", () => {
- const menu = wrapper.find("select");
- expect(menu.length).to.equal(1);
- const menuOptions = menu.find("option");
- expect(menuOptions.length).to.equal(3);
- menuOptions.forEach((o, idx) => {
- expect(o.text()).to.equal(`Option ${idx + 1}`);
- });
- });
- it("adds an item from the menu", () => {
- let removables = wrapper.find(WithRemoveButton);
- expect(removables.length).to.equal(2);
- expect(wrapper.state()["listItems"].includes("Option 1")).to.be.false;
- const menu = wrapper.find("select");
- expect(menu.getDOMNode().value).to.equal("Option 1");
- const addButton = wrapper.find(Button).last();
- addButton.simulate("click");
- removables = wrapper.find(WithRemoveButton);
- expect(removables.length).to.equal(3);
- expect(removables.last().find(EditableInput).prop("value")).to.equal(
- "Option 1"
- );
- expect(wrapper.state()["listItems"].includes("Option 1")).to.be.true;
- });
- it("doesn't lose options when nothing is selected", () => {
- const setting = {
- ...wrapper.prop("setting"),
- ...{ type: "menu", default: null, format: "narrow" },
- };
- wrapper = mount(
- ,
- { context }
- );
- const options = wrapper.find("option");
- expect(options.length).to.equal(3);
- });
- it("optionally eliminates already-selected options from the menu", () => {
- let options = wrapper.find("option");
- expect(options.length).to.equal(3);
- expect(options.map((o) => o.text()).includes("Option 1")).to.be.true;
- wrapper.find(Button).last().simulate("click");
- options = wrapper.find("option");
- expect(options.length).to.equal(3);
- expect(options.map((o) => o.text()).includes("Option 1")).to.be.true;
-
- const narrowSetting = {
- ...wrapper.prop("setting"),
- ...{ format: "narrow" },
- };
- wrapper.setProps({ setting: narrowSetting });
-
- wrapper.find(Button).last().simulate("click");
- options = wrapper.find("option");
- expect(options.length).to.equal(2);
- expect(options.map((o) => o.text()).includes("Option 1")).to.be.false;
- });
- it("optionally renders a label", () => {
- const settingWithLabel = {
- ...wrapper.prop("setting"),
- ...{ menuTitle: "Custom Menu Title" },
- };
- wrapper.setProps({ setting: settingWithLabel });
- const label = wrapper.find("select").closest("label");
- expect(label.text()).to.contain("Custom Menu Title");
- });
- it("optionally marks the menu as required", () => {
- let requiredText = wrapper.find(".required-field");
- expect(requiredText.length).to.equal(0);
- const requiredSetting = {
- ...wrapper.prop("setting"),
- ...{ menuTitle: "title", required: true },
- };
- wrapper.setProps({ setting: requiredSetting });
- requiredText = wrapper.find(".required-field");
- expect(requiredText.length).to.equal(1);
- expect(requiredText.text()).to.equal("Required");
- });
- it("optionally renders an alternate value if there are no list items", () => {
- let placeholder = wrapper.find(".input-list > span");
- expect(placeholder.length).to.equal(0);
- wrapper.setProps({ altValue: "No list items!" });
- placeholder = wrapper.find(".input-list > span");
- // There are still list items, so the placeholder isn't rendered yet.
- expect(placeholder.length).to.equal(0);
- wrapper.setProps({ value: [] });
- placeholder = wrapper.find(".input-list > span");
- expect(placeholder.length).to.equal(1);
- expect(placeholder.text()).to.equal("No list items!");
- });
- it("optionally renders an alternate value if all the available list items have already been added", () => {
- wrapper.setProps({
- value: ["Option 1", "Option 2", "Option 3"],
- onEmpty: "You've run out of options!",
- setting: { ...wrapper.prop("setting"), ...{ format: "narrow" } },
- });
- wrapper.update();
- const menu = wrapper.find("select");
- expect(menu.length).to.equal(0);
- const message = wrapper.find(".add-list-item-container span");
- expect(message.text()).to.equal("You've run out of options!");
- });
- it("renders option elements if necessary", () => {
- // If the setting does not have a menuOptions property (e.g. because it has come from the server without being modified),
- // InputList will use its options property to generate the dropdown menu with basic default values.
- const options = [
- { key: "key_1", label: "label_1" },
- { key: "key_2", label: "label_2" },
- ];
- const setting = {
- ...wrapper.prop("setting"),
- ...{ options: options, menuOptions: null },
- };
- wrapper = mount(
- ,
- { context }
- );
- const select = wrapper.find("select");
- expect(select.length).to.equal(1);
- expect(select.find("option").at(0).prop("value")).to.equal("key_1");
- expect(select.find("option").at(0).text()).to.equal("label_1");
- expect(select.find("option").at(1).prop("value")).to.equal("key_2");
- expect(select.find("option").at(1).text()).to.equal("label_2");
- });
- it("renders the default values", () => {
- const setting = {
- ...wrapper.prop("setting"),
- ...{ default: ["Option 1", "Option 2"] },
- };
- wrapper = mount(
- ,
- { context }
- );
- const defaultItems = wrapper.find(EditableInput);
- expect(defaultItems.length).to.equal(3);
- expect(defaultItems.at(0).prop("value")).to.equal("Option 1");
- expect(defaultItems.at(1).prop("value")).to.equal("Option 2");
- });
- });
-});
diff --git a/src/components/__tests__/LaneCustomListsEditor-test.tsx b/src/components/__tests__/LaneCustomListsEditor-test.tsx
deleted file mode 100644
index 9ab1928c49..0000000000
--- a/src/components/__tests__/LaneCustomListsEditor-test.tsx
+++ /dev/null
@@ -1,504 +0,0 @@
-import { expect } from "chai";
-import { stub } from "sinon";
-
-import * as React from "react";
-import { mount } from "enzyme";
-
-import { Droppable, Draggable } from "react-beautiful-dnd";
-import LaneCustomListsEditor from "../LaneCustomListsEditor";
-import ShareIcon from "../icons/ShareIcon";
-
-describe("LaneCustomListsEditor", () => {
- let onUpdate;
-
- const allCustomListsData = [
- { id: 1, name: "list 1", entry_count: 0, is_owner: true, is_shared: false },
- { id: 2, name: "list 2", entry_count: 2, is_owner: true, is_shared: false },
- { id: 3, name: "list 3", entry_count: 0, is_owner: true, is_shared: false },
- ];
-
- beforeEach(() => {
- onUpdate = stub();
- });
-
- it("renders available lists", () => {
- const filteredCustomListsData = [
- allCustomListsData[0],
- allCustomListsData[2],
- ];
-
- let wrapper = mount(
-
- );
- let container = wrapper.find(".available-lists");
- expect(container.length).to.equal(1);
-
- let droppable = container.find(Droppable);
- expect(droppable.length).to.equal(1);
-
- let lists = droppable.find(Draggable);
- expect(lists.length).to.equal(2);
-
- expect(lists.at(0).text()).to.contain("list 1");
- expect(lists.at(0).text()).to.contain("Items in list: 0");
- expect(lists.at(1).text()).to.contain("list 3");
- expect(lists.at(1).text()).to.contain("Items in list: 0");
-
- wrapper = mount(
-
- );
-
- container = wrapper.find(".available-lists");
- expect(container.length).to.equal(1);
-
- droppable = container.find(Droppable);
- expect(droppable.length).to.equal(1);
-
- lists = droppable.find(Draggable);
- expect(lists.length).to.equal(1);
-
- expect(lists.at(0).text()).to.contain("list 3");
- expect(lists.at(0).text()).to.contain("Items in list: 0");
- });
-
- it("renders a share icon on available lists that are not owned by the current library", () => {
- const sharedCustomListsData = [
- ...allCustomListsData,
- {
- id: 4,
- name: "list 4",
- entry_count: 0,
- is_owner: false,
- is_shared: true,
- },
- ];
-
- const wrapper = mount(
-
- );
-
- const lists = wrapper.find(".available-lists").find(Draggable);
-
- expect(lists.length).to.equal(4);
-
- expect(lists.at(0).find(ShareIcon).length).to.equal(0);
- expect(lists.at(1).find(ShareIcon).length).to.equal(0);
- expect(lists.at(2).find(ShareIcon).length).to.equal(0);
- expect(lists.at(3).find(ShareIcon).length).to.equal(1);
- });
-
- it("renders filter select", () => {
- const changeFilter = stub();
-
- const wrapper = mount(
-
- );
-
- const select = wrapper.find('select[name="filter"]');
-
- expect(select.prop("value")).to.equal("owned");
-
- const options = select.find("option");
-
- expect(options.length).to.equal(3);
-
- expect(options.at(0).prop("value")).to.equal("");
- expect(options.at(1).prop("value")).to.equal("owned");
- expect(options.at(2).prop("value")).to.equal("shared-in");
-
- select.getDOMNode().value = "shared-in";
- select.simulate("change");
-
- expect(changeFilter.callCount).to.equal(1);
- expect(changeFilter.args[0]).to.deep.equal(["shared-in"]);
- });
-
- it("renders current lists", () => {
- let wrapper = mount(
-
- );
- let container = wrapper.find(".current-lists");
- expect(container.length).to.equal(1);
-
- let droppable = container.find(Droppable);
- expect(droppable.length).to.equal(1);
-
- let lists = droppable.find(Draggable);
- expect(lists.length).to.equal(0);
-
- wrapper = mount(
-
- );
-
- container = wrapper.find(".current-lists");
- expect(container.length).to.equal(1);
-
- droppable = container.find(Droppable);
- expect(droppable.length).to.equal(1);
-
- lists = droppable.find(Draggable);
- expect(lists.length).to.equal(2);
-
- expect(lists.at(0).text()).to.contain("list 2");
- expect(lists.at(0).text()).to.contain("Items in list: 2");
- expect(lists.at(1).text()).to.contain("list 3");
- expect(lists.at(1).text()).to.contain("Items in list: 0");
- });
-
- it("renders a share icon on current lists that are not owned by the current library", () => {
- const sharedCustomListsData = [
- ...allCustomListsData,
- {
- id: 4,
- name: "list 4",
- entry_count: 0,
- is_owner: false,
- is_shared: true,
- },
- ];
-
- const wrapper = mount(
-
- );
-
- const lists = wrapper.find(".current-lists").find(Draggable);
-
- expect(lists.length).to.equal(2);
-
- expect(lists.at(0).find(ShareIcon).length).to.equal(0);
- expect(lists.at(1).find(ShareIcon).length).to.equal(1);
- });
-
- it("prevents dragging within available lists", () => {
- const wrapper = mount(
-
- );
-
- // simulate starting a drag from available lists
- (wrapper.instance() as LaneCustomListsEditor).onDragStart({
- draggableId: 1,
- source: {
- droppableId: "available-lists",
- },
- });
-
- const container = wrapper.find(".available-lists");
- const droppable = container.find(Droppable);
- expect(droppable.prop("isDropDisabled")).to.equal(true);
- });
-
- it("prevents dragging within current lists", () => {
- const wrapper = mount(
-
- );
-
- // simulate starting a drag from current lists
- (wrapper.instance() as LaneCustomListsEditor).onDragStart({
- draggableId: 1,
- source: {
- droppableId: "current-lists",
- },
- });
-
- const container = wrapper.find(".current-lists");
- const droppable = container.find(Droppable);
- expect(droppable.prop("isDropDisabled")).to.equal(true);
- });
-
- it("drags from available lists to current lists", () => {
- const wrapper = mount(
-
- );
-
- // simulate starting a drag from available lists
- (wrapper.instance() as LaneCustomListsEditor).onDragStart({
- draggableId: 2,
- source: {
- droppableId: "available-lists",
- },
- });
- wrapper.update();
-
- let currentContainer = wrapper.find(".current-lists");
- let droppable = currentContainer.find(Droppable);
- expect(droppable.prop("isDropDisabled")).to.equal(false);
-
- // simulate dropping on the current lists
- (wrapper.instance() as LaneCustomListsEditor).onDragEnd({
- draggableId: 2,
- source: {
- droppableId: "available-lists",
- },
- destination: {
- droppableId: "current-lists",
- },
- });
- wrapper.update();
-
- currentContainer = wrapper.find(".current-lists");
- droppable = currentContainer.find(Droppable);
- // The dropped item has been added to the current lists.
- const lists = droppable.find(Draggable);
- expect(lists.length).to.equal(2);
- expect(lists.at(0).text()).to.contain("list 1");
- expect(lists.at(1).text()).to.contain("list 2");
- expect(onUpdate.callCount).to.equal(1);
- expect(onUpdate.args[0][0]).to.deep.equal([1, 2]);
- });
-
- it("shows message in place of available lists when dragging from current lists", () => {
- const wrapper = mount(
-
- );
-
- // simulate starting a drag from current lists
- (wrapper.instance() as LaneCustomListsEditor).onDragStart({
- draggableId: 1,
- source: {
- droppableId: "current-lists",
- },
- });
- wrapper.update();
-
- let availableContainer = wrapper.find(".available-lists");
- let droppable = availableContainer.find(Droppable);
- let message = droppable.find("p");
- expect(droppable.prop("isDropDisabled")).to.equal(false);
- expect(message.length).to.equal(1);
- expect(message.text()).to.contain("here to remove");
-
- // if you drop anywhere on the page, the mssage goes away.
- // simulate dropping outside a droppable (no destination)
- (wrapper.instance() as LaneCustomListsEditor).onDragEnd({
- draggableId: 1,
- source: {
- droppableId: "current-lists",
- },
- });
- wrapper.update();
-
- availableContainer = wrapper.find(".available-lists");
- droppable = availableContainer.find(Droppable);
- message = droppable.find("p");
- expect(droppable.prop("isDropDisabled")).to.equal(true);
- expect(message.length).to.equal(0);
- });
-
- it("drags from current lists to available lists", () => {
- const wrapper = mount(
-
- );
-
- // simulate starting a drag from current lists
- (wrapper.instance() as LaneCustomListsEditor).onDragStart({
- draggableId: 1,
- source: {
- droppableId: "current-lists",
- },
- });
- wrapper.update();
-
- const availableContainer = wrapper.find(".available-lists");
- let droppable = availableContainer.find(Droppable);
- expect(droppable.prop("isDropDisabled")).to.equal(false);
-
- // simulate dropping on the available lists
- (wrapper.instance() as LaneCustomListsEditor).onDragEnd({
- draggableId: 1,
- source: {
- droppableId: "current-lists",
- },
- destination: {
- droppableId: "available-lists",
- },
- });
- wrapper.update();
- wrapper.setProps({ customListIds: onUpdate.args[0][0] });
-
- // the dropped item has been removed from the current lists
- const currentContainer = wrapper.find(".current-lists");
- droppable = currentContainer.find(Droppable);
- const lists = droppable.find(Draggable);
-
- expect(lists.length).to.equal(1);
- expect(lists.at(0).text()).to.contain("list 2");
- expect(onUpdate.callCount).to.equal(1);
- expect(onUpdate.args[0][0]).to.deep.equal([2]);
- });
-
- it("adds a list to the lane", () => {
- const wrapper = mount(
-
- );
-
- const addLink = wrapper.find(".available-lists .links button");
- addLink.at(0).simulate("click");
-
- // the item has been added to the current lists
- const currentContainer = wrapper.find(".current-lists");
- const droppable = currentContainer.find(Droppable);
- const lists = droppable.find(Draggable);
- expect(lists.length).to.equal(2);
- expect(lists.at(0).text()).to.contain("list 1");
- expect(onUpdate.callCount).to.equal(1);
- expect(onUpdate.args[0][0]).to.contain(1);
- expect(onUpdate.args[0][0]).to.contain(2);
- });
-
- it("removes a list from the lane", () => {
- const wrapper = mount(
-
- );
-
- const deleteLink = wrapper.find(".current-lists .links button");
- deleteLink.at(0).simulate("click");
- wrapper.setProps({ customListIds: onUpdate.args[0][0] });
- // this list has been removed from the current lists
- const currentContainer = wrapper.find(".current-lists");
- const droppable = currentContainer.find(Droppable);
- const lists = droppable.find(Draggable);
- expect(lists.length).to.equal(1);
- expect(lists.at(0).text()).to.contain("list 2");
- expect(onUpdate.callCount).to.equal(1);
- expect(onUpdate.args[0][0]).to.deep.equal([2]);
- });
-
- it("resets", () => {
- const wrapper = mount(
-
- );
-
- // simulate dropping a list on the current lists
- (wrapper.instance() as LaneCustomListsEditor).onDragEnd({
- draggableId: 2,
- source: {
- droppableId: "available-lists",
- },
- destination: {
- droppableId: "current-lists",
- },
- });
-
- // Set customListIds to the new array of list IDs for this lane ([1, 2]), which got passed to
- // onUpdate when we added list 2 to the current lists
- wrapper.setProps({ customListIds: onUpdate.args[0][0] });
- expect(
- (wrapper.instance() as LaneCustomListsEditor).getCustomListIds().length
- ).to.equal(2);
- expect(onUpdate.callCount).to.equal(1);
- (wrapper.instance() as LaneCustomListsEditor).reset([1]);
- // Calling reset passes the original array of list IDs ([1]) to onUpdate
- wrapper.setProps({ customListIds: onUpdate.args[1][0] });
- expect(
- (wrapper.instance() as LaneCustomListsEditor).getCustomListIds().length
- ).to.equal(1);
- expect(onUpdate.callCount).to.equal(2);
-
- // simulate dropping a list on the available lists
- (wrapper.instance() as LaneCustomListsEditor).onDragEnd({
- draggableId: 1,
- source: {
- droppableId: "current-lists",
- },
- destination: {
- droppableId: "available-lists",
- },
- });
-
- // Set customListIds to the new array of list IDs for this lane ([]), which got passed to
- // onUpdate when we removed list 1 from the current lists
- wrapper.setProps({ customListIds: onUpdate.args[2][0] });
- expect(
- (wrapper.instance() as LaneCustomListsEditor).getCustomListIds().length
- ).to.equal(0);
- expect(onUpdate.callCount).to.equal(3);
- (wrapper.instance() as LaneCustomListsEditor).reset([1]);
- // Calling reset passes the original array of list IDs ([1]) to onUpdate
- wrapper.setProps({ customListIds: onUpdate.args[3][0] });
- expect(
- (wrapper.instance() as LaneCustomListsEditor).getCustomListIds().length
- ).to.equal(1);
- expect(onUpdate.callCount).to.equal(4);
- });
-});
diff --git a/src/components/__tests__/PairedMenus-test.tsx b/src/components/__tests__/PairedMenus-test.tsx
deleted file mode 100644
index a162d2c5e5..0000000000
--- a/src/components/__tests__/PairedMenus-test.tsx
+++ /dev/null
@@ -1,184 +0,0 @@
-import { expect } from "chai";
-import { spy } from "sinon";
-
-import * as React from "react";
-import { mount } from "enzyme";
-import PairedMenus from "../PairedMenus";
-import InputList from "../InputList";
-
-describe("PairedMenus", () => {
- let wrapper;
- let inputListSetting;
- let dropdownSetting;
- beforeEach(() => {
- inputListSetting = {
- key: "input",
- label: "Input List",
- default: ["a", "b", "c"],
- options: [
- { key: "a", label: "A" },
- { key: "b", label: "B" },
- { key: "c", label: "C" },
- { key: "d", label: "D" },
- { key: "e", label: "E" },
- ],
- paired: "dropdown",
- };
- dropdownSetting = {
- key: "dropdown",
- label: "Dropdown",
- default: "b",
- options: [
- { key: "a", label: "A" },
- { key: "b", label: "B" },
- { key: "c", label: "C" },
- { key: "d", label: "D" },
- { key: "e", label: "E" },
- ],
- type: "select",
- };
- wrapper = mount(
-
- );
- });
- const menuOptions = () => {
- return inputListSetting.options.map((o) => (
-
- ));
- };
- it("renders a fieldset with an InputList and a dropdown", () => {
- const fieldset = wrapper.find("fieldset");
- expect(fieldset.length).to.equal(1);
- expect(fieldset.find(InputList).length).to.equal(1);
- expect(fieldset.find("select").at(1).prop("name")).to.equal("dropdown");
- });
- it("passes down the inputListSetting prop", () => {
- const inputList = wrapper.find(InputList);
- const modifiedSetting = {
- ...inputListSetting,
- ...{ format: "narrow", type: "menu", menuOptions: menuOptions },
- };
- Object.keys(inputList.prop("setting")).forEach(
- (k) =>
- k !== "menuOptions" &&
- expect(inputList.prop("setting")[k]).to.eql(modifiedSetting[k])
- );
- inputList
- .find("input")
- .forEach((x) => expect(x.props().readOnly).to.be.true);
- menuOptions().forEach((o, idx) => {
- expect(o.key).to.equal(inputList.prop("setting").menuOptions[idx].key);
- expect(o.value).to.equal(
- inputList.prop("setting").menuOptions[idx].value
- );
- });
- });
- it("passes down the dropdownSetting prop", () => {
- const dropdown = wrapper.find("select").at(1);
- expect(dropdown.prop("name")).to.equal(dropdownSetting.key);
- expect(dropdown.prop("value")).to.equal(dropdownSetting.default);
- menuOptions().forEach((o, idx) => {
- if (idx > wrapper.state().inputListValues.length - 1) {
- return;
- }
- expect(o.key).to.equal(dropdown.children().at(idx).props().value);
- expect(o.props.value).to.equal(
- dropdown.children().at(idx).props().children
- );
- });
- });
- it("optionally disables the InputList's button", () => {
- let inputList = wrapper.find(InputList);
- expect(inputList.prop("disableButton")).not.to.be.true;
- wrapper.setProps({ readOnly: true });
- inputList = wrapper.find(InputList);
- expect(inputList.prop("disableButton")).to.be.true;
- });
- it("calculates state", () => {
- expect(wrapper.state().inputListValues).to.eql(inputListSetting.default);
- expect(wrapper.state().dropdownValue).to.equal(dropdownSetting.default);
- });
- it("takes an optional item prop", () => {
- const item = {
- name: "Library",
- short_name: "lib",
- uuid: "123",
- settings: {
- input: ["c", "d", "e"],
- dropdown: "d",
- },
- };
- wrapper = mount(
-
- );
- expect(wrapper.state().inputListValues).to.eql(item.settings.input);
- expect(wrapper.state().dropdownValue).to.equal(item.settings.dropdown);
-
- const inputList = wrapper.find(InputList);
- expect(inputList.find("input").map((x) => x.props().value)).to.eql(
- item.settings.input.map((x) => x.toUpperCase())
- );
- expect(inputList.find("input").map((x) => x.props().name)).to.eql(
- item.settings.input.map((x) => `input_${x}`)
- );
-
- const dropdown = wrapper.find("select").at(1);
- expect(dropdown.props().value).to.equal(item.settings.dropdown);
- });
- it("updates the InputList", () => {
- const spyUpdateInputList = spy(wrapper.instance(), "updateInputList");
- wrapper.setProps({ updateInputList: spyUpdateInputList });
- wrapper.find(InputList).prop("onChange")({
- listItems: ["a", "b", "c", "d"],
- });
- expect(spyUpdateInputList.callCount).to.equal(1);
- expect(spyUpdateInputList.args[0][0]).to.eql({
- listItems: ["a", "b", "c", "d"],
- });
- expect(wrapper.state().inputListValues).to.eql(["a", "b", "c", "d"]);
- spyUpdateInputList.restore();
- });
- it("updates the dropdown", () => {
- const spyOnDropdownChange = spy(wrapper.instance(), "onDropdownChange");
- wrapper.setProps({ onDropdownChange: spyOnDropdownChange });
- wrapper.find("select").at(1).getDOMNode().value = "c";
- wrapper.find("select").at(1).simulate("change");
- expect(spyOnDropdownChange.callCount).to.equal(1);
- expect(spyOnDropdownChange.args[0][0]).to.equal("c");
- expect(wrapper.state().dropdownValue).to.equal("c");
- spyOnDropdownChange.restore();
- });
- it("calculates the dropdown options based on the InputList values", () => {
- let dropdownOptions = wrapper
- .find("select")
- .at(1)
- .find("option")
- .map((o) => o.text());
- expect(dropdownOptions).to.eql(["A", "B", "C"]);
- wrapper.instance().updateInputList({ listItems: ["d", "e"] });
- wrapper.update();
- dropdownOptions = wrapper
- .find("select")
- .at(1)
- .find("option")
- .map((o) => o.text());
- expect(dropdownOptions).to.eql(["D", "E"]);
- });
- it("displays a message if there are no values available in the dropdown", () => {
- wrapper.setState({ inputListValues: [] });
- expect(wrapper.find("select").length).to.equal(1);
- expect(wrapper.find(".bg-warning").length).to.equal(1);
- expect(wrapper.find(".bg-warning").text()).to.equal(
- "In order to set this value, you must add at least one option from the menu above."
- );
- });
-});
diff --git a/tests/jest/components/CustomListEntriesEditor.test.tsx b/tests/jest/components/CustomListEntriesEditor.test.tsx
new file mode 100644
index 0000000000..7dce70eb7d
--- /dev/null
+++ b/tests/jest/components/CustomListEntriesEditor.test.tsx
@@ -0,0 +1,1240 @@
+import * as React from "react";
+import { render, screen, within, act } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+
+import CustomListEntriesEditor, {
+ CustomListEntriesEditorProps,
+} from "../../../src/components/CustomListEntriesEditor";
+
+// react-beautiful-dnd does not work in jsdom, so we do not exercise real
+// pointer/keyboard drag gestures. Instead we mock the library:
+// - DragDropContext captures the component's onDragStart/onDragEnd handlers so
+// a test can invoke them directly with a synthetic drag "result".
+// - Droppable renders its children render-prop and exposes the `isDropDisabled`
+// prop it received as a DOM data attribute, so the drag-prevention behaviour
+// driven by that prop stays observable.
+// - Draggable simply renders its children render-prop.
+type DragResult = {
+ draggableId?: string;
+ source: { droppableId: string };
+ destination?: { droppableId: string };
+};
+
+type DndCallbacks = {
+ onDragStart: (result: DragResult) => void;
+ onDragEnd: (result: DragResult) => void;
+};
+
+jest.mock("react-beautiful-dnd", () => ({
+ __esModule: true,
+ DragDropContext: ({ onDragStart, onDragEnd, children }) => {
+ (
+ globalThis as typeof globalThis & { __dndCallbacks?: DndCallbacks }
+ ).__dndCallbacks = { onDragStart, onDragEnd };
+ return children;
+ },
+ Droppable: ({ droppableId, isDropDisabled, children }) => (
+
+ {children(
+ { innerRef: () => undefined, droppableProps: {}, placeholder: null },
+ { isDraggingOver: false }
+ )}
+
+ ),
+ Draggable: ({ children }) =>
+ children(
+ {
+ innerRef: () => undefined,
+ draggableProps: {},
+ dragHandleProps: {},
+ draggableStyle: {},
+ placeholder: null,
+ },
+ { isDragging: false }
+ ),
+}));
+
+// CatalogLink from web-opds-client needs the legacy react-router `pathFor`/
+// `router` context to render. The component only distinguishes its links by
+// their `bookUrl`/`collectionUrl` props and "View details" text, so mock it to
+// a marker that surfaces exactly those. `renderCatalogLink` in the component
+// still runs, so its coverage is preserved.
+jest.mock(
+ "@thepalaceproject/web-opds-client/lib/components/CatalogLink",
+ () => ({
+ __esModule: true,
+ default: ({ bookUrl, collectionUrl, children }) => (
+
+ {children}
+
+ ),
+ })
+);
+
+const getDndCallbacks = (): DndCallbacks =>
+ (globalThis as typeof globalThis & { __dndCallbacks: DndCallbacks })
+ .__dndCallbacks;
+
+describe("CustomListEntriesEditor", () => {
+ // The component calls Element.scrollTo on mount/update, which jsdom lacks.
+ Element.prototype.scrollTo = () => {};
+
+ let addAllEntries: jest.Mock;
+ let addEntry: jest.Mock;
+ let deleteAllEntries: jest.Mock;
+ let deleteEntry: jest.Mock;
+ let loadMoreSearchResults: jest.Mock;
+ let loadMoreEntries: jest.Mock;
+ let refreshResults: jest.Mock;
+
+ const makeSearchResults = (): any => ({
+ id: "id",
+ url: "url",
+ title: "title",
+ lanes: [],
+ navigationLinks: [],
+ books: [
+ {
+ id: "1",
+ title: "result 1",
+ authors: ["author 1"],
+ url: "/some/url1",
+ language: "eng",
+ raw: {
+ $: { "schema:additionalType": { value: "http://schema.org/EBook" } },
+ },
+ },
+ {
+ id: "2",
+ title: "result 2",
+ authors: ["author 2a", "author 2b"],
+ url: "/some/url2",
+ language: "eng",
+ raw: {
+ $: {
+ "schema:additionalType": {
+ value: "http://bib.schema.org/Audiobook",
+ },
+ },
+ },
+ },
+ {
+ id: "3",
+ title: "result 3",
+ authors: ["author 3"],
+ url: "/some/url3",
+ language: "eng",
+ raw: {
+ $: { "schema:additionalType": { value: "http://schema.org/EBook" } },
+ },
+ },
+ ],
+ });
+
+ const makeEntries = (): any => [
+ {
+ id: "A",
+ title: "entry A",
+ authors: ["author A"],
+ url: "/some/urlA",
+ raw: {
+ $: { "schema:additionalType": { value: "http://schema.org/EBook" } },
+ },
+ },
+ {
+ id: "B",
+ title: "entry B",
+ authors: ["author B1", "author B2"],
+ url: "/some/urlB",
+ raw: {
+ $: {
+ "schema:additionalType": { value: "http://bib.schema.org/Audiobook" },
+ },
+ },
+ },
+ ];
+
+ type EditorOverrides = Partial<
+ Omit<
+ CustomListEntriesEditorProps,
+ "loadMoreSearchResults" | "loadMoreEntries"
+ >
+ > & {
+ loadMoreSearchResults?: (() => void) | null;
+ loadMoreEntries?: (() => void) | null;
+ };
+
+ // Builds the element with the always-required props filled in, so each test
+ // only supplies what it cares about. Used both for `render` and `rerender`
+ // (which replaces, rather than merges, props).
+ const editor = (overrides: EditorOverrides = {}) => (
+ )}
+ />
+ );
+
+ const searchSection = (container: HTMLElement) =>
+ container.querySelector(".custom-list-search-results") as HTMLElement;
+
+ const entriesSection = (container: HTMLElement) =>
+ container.querySelector(".custom-list-entries") as HTMLElement;
+
+ const entriesHeadingText = (container: HTMLElement) =>
+ entriesSection(container).querySelector("h4")?.textContent;
+
+ beforeEach(() => {
+ addAllEntries = jest.fn();
+ addEntry = jest.fn();
+ deleteAllEntries = jest.fn();
+ deleteEntry = jest.fn();
+ loadMoreSearchResults = jest.fn();
+ loadMoreEntries = jest.fn();
+ refreshResults = jest.fn();
+ });
+
+ afterEach(() => {
+ document.body.classList.remove("dragging");
+ });
+
+ it("renders search results", () => {
+ const { container } = render(
+ editor({
+ entries: makeEntries(),
+ isOwner: true,
+ searchResults: makeSearchResults(),
+ })
+ );
+
+ const section = searchSection(container);
+ expect(section).toBeInTheDocument();
+
+ const results = section.querySelectorAll(".search-result");
+ expect(results).toHaveLength(3);
+
+ const scoped = within(section);
+ expect(scoped.getByText("result 1")).toBeInTheDocument();
+ expect(scoped.getByText("author 1")).toBeInTheDocument();
+ expect(scoped.getByText("result 2")).toBeInTheDocument();
+ expect(scoped.getByText("author 2a, author 2b")).toBeInTheDocument();
+ expect(scoped.getByText("result 3")).toBeInTheDocument();
+ expect(scoped.getByText("author 3")).toBeInTheDocument();
+ });
+
+ it("does not render search results when isOwner is false", () => {
+ const { container } = render(
+ editor({
+ entries: makeEntries(),
+ isOwner: false,
+ searchResults: makeSearchResults(),
+ })
+ );
+
+ expect(
+ container.querySelector(".custom-list-search-results")
+ ).not.toBeInTheDocument();
+ });
+
+ it("calls refreshResults when the refresh button is clicked", async () => {
+ const user = userEvent.setup();
+ render(
+ editor({
+ entries: makeEntries(),
+ isOwner: true,
+ searchResults: makeSearchResults(),
+ refreshResults,
+ })
+ );
+
+ await user.click(screen.getByRole("button", { name: "Refresh" }));
+
+ expect(refreshResults).toHaveBeenCalledTimes(1);
+ });
+
+ it("disables the refresh button when isFetchingSearchResults is true", () => {
+ render(
+ editor({
+ entries: makeEntries(),
+ isOwner: true,
+ searchResults: makeSearchResults(),
+ refreshResults,
+ isFetchingSearchResults: true,
+ })
+ );
+
+ expect(screen.getByRole("button", { name: "Refresh" })).toBeDisabled();
+ });
+
+ it("enables the refresh button when isFetchingSearchResults is false", () => {
+ render(
+ editor({
+ entries: makeEntries(),
+ isOwner: true,
+ searchResults: makeSearchResults(),
+ refreshResults,
+ isFetchingSearchResults: false,
+ })
+ );
+
+ expect(screen.getByRole("button", { name: "Refresh" })).toBeEnabled();
+ });
+
+ it("shows a loading message when isFetchingSearchResults is true", () => {
+ const { container } = render(
+ editor({
+ entries: makeEntries(),
+ isOwner: true,
+ searchResults: makeSearchResults(),
+ refreshResults,
+ isFetchingSearchResults: true,
+ })
+ );
+
+ const loadingIndicator = container.querySelector(".list-loading");
+ expect(loadingIndicator).toBeInTheDocument();
+ expect(loadingIndicator).toHaveTextContent("Loading");
+ });
+
+ it("renders a link to view each search result", () => {
+ const { container } = render(
+ editor({
+ entries: makeEntries(),
+ isOwner: true,
+ searchResults: makeSearchResults(),
+ })
+ );
+
+ const links = within(searchSection(container)).getAllByTestId(
+ "catalog-link"
+ );
+ expect(links).toHaveLength(3);
+
+ expect(links[0]).toHaveTextContent("View details");
+ expect(links[0]).toHaveAttribute("data-book-url", "/some/url1");
+ expect(links[1]).toHaveTextContent("View details");
+ expect(links[1]).toHaveAttribute("data-book-url", "/some/url2");
+ expect(links[2]).toHaveTextContent("View details");
+ expect(links[2]).toHaveAttribute("data-book-url", "/some/url3");
+ });
+
+ it("does not include the opds feed url in links to view search results", () => {
+ const { container } = render(
+ editor({
+ entries: makeEntries(),
+ isOwner: true,
+ opdsFeedUrl: "opdsFeedUrl",
+ searchResults: makeSearchResults(),
+ })
+ );
+
+ const links = within(searchSection(container)).getAllByTestId(
+ "catalog-link"
+ );
+
+ links.forEach((link) => {
+ expect(link).not.toHaveAttribute("data-collection-url");
+ });
+ });
+
+ it("renders an SVG icon for each search result", () => {
+ const { container } = render(
+ editor({
+ entries: makeEntries(),
+ isOwner: true,
+ searchResults: makeSearchResults(),
+ })
+ );
+
+ const section = searchSection(container);
+ expect(section.querySelectorAll(".audio-headphone-icon")).toHaveLength(1);
+ expect(section.querySelectorAll(".book-icon")).toHaveLength(2);
+ });
+
+ it("doesn't render an SVG icon for books with a bad medium value", () => {
+ const searchResults = makeSearchResults();
+ searchResults.books[2].raw["$"]["schema:additionalType"].value = "";
+
+ const { container } = render(
+ editor({
+ entries: makeEntries(),
+ isOwner: true,
+ searchResults,
+ })
+ );
+
+ const section = searchSection(container);
+ expect(section.querySelectorAll(".audio-headphone-icon")).toHaveLength(1);
+ expect(section.querySelectorAll(".book-icon")).toHaveLength(1);
+ });
+
+ it("renders list entries", () => {
+ const { container } = render(
+ editor({
+ autoUpdate: false,
+ entries: makeEntries(),
+ isOwner: true,
+ entryCount: 2,
+ })
+ );
+
+ const section = entriesSection(container);
+ expect(section).toBeInTheDocument();
+
+ const entries = section.querySelectorAll(".custom-list-entry");
+ expect(entries).toHaveLength(2);
+
+ const scoped = within(section);
+ expect(scoped.getByText("entry A")).toBeInTheDocument();
+ expect(scoped.getByText("author A")).toBeInTheDocument();
+ expect(scoped.getByText("entry B")).toBeInTheDocument();
+ expect(scoped.getByText("author B1, author B2")).toBeInTheDocument();
+
+ expect(entriesHeadingText(container)).toBe(
+ "List Entries: Displaying 1 - 2 of 2 books"
+ );
+ });
+
+ it("makes list entries read only if autoUpdate is true", () => {
+ const { container } = render(
+ editor({
+ autoUpdate: true,
+ entries: makeEntries(),
+ isOwner: true,
+ entryCount: 2,
+ })
+ );
+
+ const section = entriesSection(container);
+
+ expect(section.querySelectorAll(".droppable-header button")).toHaveLength(
+ 0
+ );
+ expect(section.querySelectorAll(".custom-list-entry button")).toHaveLength(
+ 0
+ );
+ });
+
+ it("renders an auto update status if autoUpdate is true", () => {
+ const base = {
+ entries: makeEntries(),
+ isOwner: true,
+ entryCount: 2,
+ autoUpdate: true,
+ };
+
+ const { container, rerender } = render(
+ editor({ ...base, autoUpdateStatus: "" })
+ );
+
+ const statusText = () =>
+ container.querySelector(".custom-list-entries .auto-update-status-name")
+ ?.textContent;
+
+ expect(statusText()).toBe("Status: New");
+
+ // Without a listId, the status is always "New", even if search is modified.
+ rerender(editor({ ...base, autoUpdateStatus: "", isSearchModified: true }));
+ expect(statusText()).toBe("Status: New");
+
+ rerender(
+ editor({
+ ...base,
+ listId: "123",
+ isSearchModified: false,
+ autoUpdateStatus: "init",
+ })
+ );
+ expect(statusText()).toBe("Status: Initializing");
+
+ rerender(
+ editor({
+ ...base,
+ listId: "123",
+ isSearchModified: true,
+ autoUpdateStatus: "init",
+ })
+ );
+ expect(statusText()).toBe("Status: Search criteria modified");
+
+ rerender(
+ editor({
+ ...base,
+ listId: "123",
+ isSearchModified: false,
+ autoUpdateStatus: "updated",
+ })
+ );
+ expect(statusText()).toBe("Status: Updated");
+
+ rerender(
+ editor({
+ ...base,
+ listId: "123",
+ isSearchModified: true,
+ autoUpdateStatus: "updated",
+ })
+ );
+ expect(statusText()).toBe("Status: Search criteria modified");
+
+ rerender(
+ editor({
+ ...base,
+ listId: "123",
+ isSearchModified: false,
+ autoUpdateStatus: "repopulate",
+ })
+ );
+ expect(statusText()).toBe("Status: Repopulating");
+
+ rerender(
+ editor({
+ ...base,
+ listId: "123",
+ isSearchModified: true,
+ autoUpdateStatus: "repopulate",
+ })
+ );
+ expect(statusText()).toBe("Status: Search criteria modified");
+
+ // A saved list whose autoUpdateStatus is empty is being switched to
+ // automatic updates.
+ rerender(
+ editor({
+ ...base,
+ listId: "123",
+ isSearchModified: false,
+ autoUpdateStatus: "",
+ })
+ );
+ expect(statusText()).toBe("Status: Changing to automatic");
+
+ // Any other autoUpdateStatus value is shown verbatim.
+ rerender(
+ editor({
+ ...base,
+ listId: "123",
+ isSearchModified: false,
+ autoUpdateStatus: "some-custom-status",
+ })
+ );
+ expect(statusText()).toBe("Status: some-custom-status");
+
+ rerender(editor({ ...base, autoUpdate: false }));
+ expect(
+ container.querySelector(".custom-list-entries .auto-update-status-name")
+ ).not.toBeInTheDocument();
+ });
+
+ it("renders a link to view each entry", () => {
+ const { container } = render(
+ editor({
+ entries: makeEntries(),
+ isOwner: true,
+ opdsFeedUrl: "opdsFeedUrl",
+ entryCount: 2,
+ })
+ );
+
+ const links = within(entriesSection(container)).getAllByTestId(
+ "catalog-link"
+ );
+ expect(links).toHaveLength(2);
+
+ expect(links[0]).toHaveTextContent("View details");
+ expect(links[0]).toHaveAttribute("data-book-url", "/some/urlA");
+ expect(links[1]).toHaveTextContent("View details");
+ expect(links[1]).toHaveAttribute("data-book-url", "/some/urlB");
+ });
+
+ it("includes the opds feed url in links to view entries", () => {
+ const { container } = render(
+ editor({
+ entries: makeEntries(),
+ isOwner: true,
+ opdsFeedUrl: "opdsFeedUrl",
+ entryCount: 2,
+ })
+ );
+
+ const links = within(entriesSection(container)).getAllByTestId(
+ "catalog-link"
+ );
+
+ expect(links[0]).toHaveAttribute("data-collection-url", "opdsFeedUrl");
+ expect(links[1]).toHaveAttribute("data-collection-url", "opdsFeedUrl");
+ });
+
+ it("renders an SVG icon for each entry", () => {
+ const { container } = render(
+ editor({
+ entries: makeEntries(),
+ isOwner: true,
+ entryCount: 2,
+ })
+ );
+
+ const section = entriesSection(container);
+ expect(section.querySelectorAll(".audio-headphone-icon")).toHaveLength(1);
+ expect(section.querySelectorAll(".book-icon")).toHaveLength(1);
+ });
+
+ it("doesn't include search results that are already in the entries list when autoUpdate is false", () => {
+ const entries = [
+ {
+ id: "1",
+ title: "result 1",
+ authors: ["author 1"],
+ language: "eng",
+ raw: {
+ $: {
+ "schema:additionalType": {
+ value: "http://bib.schema.org/Audiobook",
+ },
+ },
+ },
+ },
+ ];
+
+ const { container } = render(
+ editor({
+ autoUpdate: false,
+ searchResults: makeSearchResults(),
+ entries: entries as any,
+ isOwner: true,
+ entryCount: 2,
+ })
+ );
+
+ const section = searchSection(container);
+ expect(section).toBeInTheDocument();
+
+ const results = section.querySelectorAll(".search-result");
+ expect(results).toHaveLength(2);
+
+ const scoped = within(section);
+ expect(scoped.getByText("result 2")).toBeInTheDocument();
+ expect(scoped.getByText("author 2a, author 2b")).toBeInTheDocument();
+ expect(scoped.getByText("result 3")).toBeInTheDocument();
+ expect(scoped.getByText("author 3")).toBeInTheDocument();
+ });
+
+ it("shows all search results, even ones that are in the entries list, when autoUpdate is true", () => {
+ const entries = [
+ {
+ id: "1",
+ title: "result 1",
+ authors: ["author 1"],
+ language: "eng",
+ raw: {
+ $: {
+ "schema:additionalType": {
+ value: "http://bib.schema.org/Audiobook",
+ },
+ },
+ },
+ },
+ ];
+
+ const { container } = render(
+ editor({
+ autoUpdate: true,
+ searchResults: makeSearchResults(),
+ entries: entries as any,
+ isOwner: true,
+ entryCount: 2,
+ })
+ );
+
+ const section = searchSection(container);
+ expect(section).toBeInTheDocument();
+
+ const results = section.querySelectorAll(".search-result");
+ expect(results).toHaveLength(3);
+
+ const scoped = within(section);
+ expect(scoped.getByText("result 1")).toBeInTheDocument();
+ expect(scoped.getByText("author 1")).toBeInTheDocument();
+ expect(scoped.getByText("result 2")).toBeInTheDocument();
+ expect(scoped.getByText("author 2a, author 2b")).toBeInTheDocument();
+ expect(scoped.getByText("result 3")).toBeInTheDocument();
+ expect(scoped.getByText("author 3")).toBeInTheDocument();
+ });
+
+ // The following drag-and-drop tests invoke the DragDropContext's captured
+ // onDragStart/onDragEnd handlers directly with a synthetic drag result, since
+ // react-beautiful-dnd cannot perform real drags in jsdom (see the mock above).
+
+ it("prevents dragging within search results", () => {
+ render(
+ editor({
+ entries: makeEntries(),
+ isOwner: true,
+ searchResults: makeSearchResults(),
+ })
+ );
+
+ // Simulate starting a drag from search results.
+ act(() => {
+ getDndCallbacks().onDragStart({
+ draggableId: "1",
+ source: { droppableId: "search-results" },
+ });
+ });
+
+ // The search-results droppable refuses drops (no reordering within results)...
+ expect(screen.getByTestId("droppable-search-results")).toHaveAttribute(
+ "data-drop-disabled",
+ "true"
+ );
+ // ...while list entries becomes a valid drop target — which only flips
+ // because handleDragStart recorded the drag source (it was disabled before).
+ expect(screen.getByTestId("droppable-custom-list-entries")).toHaveAttribute(
+ "data-drop-disabled",
+ "false"
+ );
+ });
+
+ it("prevents dragging within list entries", () => {
+ render(
+ editor({
+ entries: makeEntries(),
+ isOwner: true,
+ entryCount: 2,
+ })
+ );
+
+ // Simulate starting a drag from list entries.
+ act(() => {
+ getDndCallbacks().onDragStart({
+ draggableId: "A",
+ source: { droppableId: "custom-list-entries" },
+ });
+ });
+
+ // List entries refuses drops within itself...
+ expect(screen.getByTestId("droppable-custom-list-entries")).toHaveAttribute(
+ "data-drop-disabled",
+ "true"
+ );
+ // ...while search results becomes a valid drop target — which only flips
+ // because handleDragStart recorded the drag source (it was disabled before).
+ expect(screen.getByTestId("droppable-search-results")).toHaveAttribute(
+ "data-drop-disabled",
+ "false"
+ );
+ });
+
+ it("calls addEntry when a book is dragged from search results to list entries", () => {
+ render(
+ editor({
+ entries: makeEntries(),
+ isOwner: true,
+ searchResults: makeSearchResults(),
+ entryCount: 2,
+ addEntry,
+ })
+ );
+
+ // Simulate starting a drag from search results: the entries droppable now
+ // accepts drops.
+ act(() => {
+ getDndCallbacks().onDragStart({
+ draggableId: "1",
+ source: { droppableId: "search-results" },
+ });
+ });
+
+ expect(screen.getByTestId("droppable-custom-list-entries")).toHaveAttribute(
+ "data-drop-disabled",
+ "false"
+ );
+
+ // Simulate dropping on the entries.
+ act(() => {
+ getDndCallbacks().onDragEnd({
+ draggableId: "1",
+ source: { droppableId: "search-results" },
+ destination: { droppableId: "custom-list-entries" },
+ });
+ });
+
+ expect(addEntry).toHaveBeenCalledTimes(1);
+ expect(addEntry).toHaveBeenCalledWith("1");
+ });
+
+ it("shows a message in place of search results when dragging from list entries", () => {
+ render(
+ editor({
+ entries: makeEntries(),
+ isOwner: true,
+ })
+ );
+
+ // Simulate starting a drag from list entries.
+ act(() => {
+ getDndCallbacks().onDragStart({
+ draggableId: "A",
+ source: { droppableId: "custom-list-entries" },
+ });
+ });
+
+ expect(screen.getByTestId("droppable-search-results")).toHaveAttribute(
+ "data-drop-disabled",
+ "false"
+ );
+ expect(screen.getByText(/here to remove/)).toBeInTheDocument();
+
+ // If the drop occurs outside a droppable (no destination), the message goes
+ // away and the search-results droppable is disabled again.
+ act(() => {
+ getDndCallbacks().onDragEnd({
+ draggableId: "A",
+ source: { droppableId: "custom-list-entries" },
+ });
+ });
+
+ expect(screen.getByTestId("droppable-search-results")).toHaveAttribute(
+ "data-drop-disabled",
+ "true"
+ );
+ expect(screen.queryByText(/here to remove/)).not.toBeInTheDocument();
+ });
+
+ it("calls deleteEntry when a book is dragged from list entries to search results", () => {
+ render(
+ editor({
+ entries: makeEntries(),
+ isOwner: true,
+ searchResults: makeSearchResults(),
+ deleteEntry,
+ })
+ );
+
+ // Simulate starting a drag from list entries: the search-results droppable
+ // now accepts drops (to remove).
+ act(() => {
+ getDndCallbacks().onDragStart({
+ draggableId: "A",
+ source: { droppableId: "custom-list-entries" },
+ });
+ });
+
+ expect(screen.getByTestId("droppable-search-results")).toHaveAttribute(
+ "data-drop-disabled",
+ "false"
+ );
+
+ // Simulate dropping on the search results.
+ act(() => {
+ getDndCallbacks().onDragEnd({
+ draggableId: "A",
+ source: { droppableId: "custom-list-entries" },
+ destination: { droppableId: "search-results" },
+ });
+ });
+
+ expect(deleteEntry).toHaveBeenCalledTimes(1);
+ expect(deleteEntry).toHaveBeenCalledWith("A");
+ });
+
+ it("calls addEntry when the Add to List button is clicked on a search result", async () => {
+ const user = userEvent.setup();
+ const { container } = render(
+ editor({
+ entries: makeEntries(),
+ isOwner: true,
+ searchResults: makeSearchResults(),
+ entryCount: 2,
+ addEntry,
+ })
+ );
+
+ expect(entriesHeadingText(container)).toBe(
+ "List Entries: Displaying 1 - 2 of 2 books"
+ );
+
+ const addButtons = within(searchSection(container)).getAllByRole("button", {
+ name: /Add to list/,
+ });
+
+ await user.click(addButtons[0]);
+
+ expect(addEntry).toHaveBeenCalledTimes(1);
+ expect(addEntry).toHaveBeenCalledWith("1");
+ });
+
+ it("hides the Add to List buttons when autoUpdate is true", () => {
+ const { container } = render(
+ editor({
+ autoUpdate: true,
+ entries: makeEntries(),
+ isOwner: true,
+ searchResults: makeSearchResults(),
+ entryCount: 2,
+ addEntry,
+ })
+ );
+
+ expect(
+ within(searchSection(container)).queryByRole("button", {
+ name: /Add to list/,
+ })
+ ).not.toBeInTheDocument();
+ });
+
+ it("calls deleteEntry when the Remove from List button is clicked on a list entry", async () => {
+ const user = userEvent.setup();
+ const { container } = render(
+ editor({
+ entries: makeEntries(),
+ isOwner: true,
+ searchResults: makeSearchResults(),
+ entryCount: 2,
+ deleteEntry,
+ })
+ );
+
+ expect(entriesHeadingText(container)).toBe(
+ "List Entries: Displaying 1 - 2 of 2 books"
+ );
+
+ const deleteButtons = within(entriesSection(container)).getAllByRole(
+ "button",
+ { name: /Remove from list/ }
+ );
+
+ await user.click(deleteButtons[0]);
+
+ expect(deleteEntry).toHaveBeenCalledTimes(1);
+ expect(deleteEntry).toHaveBeenCalledWith("A");
+ });
+
+ it("does not render Remove from List buttons on a list entries when isOwner is false", () => {
+ const { container } = render(
+ editor({
+ entries: makeEntries(),
+ isOwner: false,
+ searchResults: makeSearchResults(),
+ entryCount: 2,
+ deleteEntry,
+ })
+ );
+
+ expect(
+ entriesSection(container).querySelectorAll(".links button")
+ ).toHaveLength(0);
+ });
+
+ it("does not render the Add All to List button when there are no search results", () => {
+ const { container } = render(
+ editor({
+ entries: makeEntries(),
+ isOwner: true,
+ })
+ );
+
+ expect(container.querySelector(".add-all-button")).not.toBeInTheDocument();
+ });
+
+ it("does not render the Add All to List button when autoUpdate is true", () => {
+ const { container } = render(
+ editor({
+ autoUpdate: true,
+ entries: makeEntries(),
+ isOwner: true,
+ searchResults: makeSearchResults(),
+ })
+ );
+
+ expect(container.querySelector(".add-all-button")).not.toBeInTheDocument();
+ });
+
+ it("does not render the Add All to List button when isOwner is false", () => {
+ const { container } = render(
+ editor({
+ entries: makeEntries(),
+ isOwner: false,
+ searchResults: makeSearchResults(),
+ })
+ );
+
+ expect(container.querySelector(".add-all-button")).not.toBeInTheDocument();
+ });
+
+ it("calls addAllEntries when the Add All to List button is clicked", async () => {
+ const user = userEvent.setup();
+ const { container } = render(
+ editor({
+ entries: makeEntries(),
+ isOwner: true,
+ searchResults: makeSearchResults(),
+ entryCount: 2,
+ addAllEntries,
+ })
+ );
+
+ expect(entriesHeadingText(container)).toBe(
+ "List Entries: Displaying 1 - 2 of 2 books"
+ );
+
+ await user.click(screen.getByRole("button", { name: /Add all to list/ }));
+
+ expect(addAllEntries).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not render the Delete all button when there are no entries", () => {
+ const { container } = render(
+ editor({
+ entries: [],
+ isOwner: true,
+ searchResults: makeSearchResults(),
+ })
+ );
+
+ expect(
+ container.querySelector(".delete-all-button")
+ ).not.toBeInTheDocument();
+ });
+
+ it("does not render the Delete all button when isOwner is false", () => {
+ const { container } = render(
+ editor({
+ entries: makeEntries(),
+ isOwner: false,
+ searchResults: makeSearchResults(),
+ })
+ );
+
+ expect(
+ container.querySelector(".delete-all-button")
+ ).not.toBeInTheDocument();
+ });
+
+ it("calls deleteAllEntries when the Delete all button is clicked", async () => {
+ const user = userEvent.setup();
+ const { container } = render(
+ editor({
+ entries: makeEntries(),
+ isOwner: true,
+ entryCount: 2,
+ deleteAllEntries,
+ })
+ );
+
+ expect(entriesHeadingText(container)).toBe(
+ "List Entries: Displaying 1 - 2 of 2 books"
+ );
+
+ await user.click(screen.getByRole("button", { name: "Delete" }));
+
+ expect(deleteAllEntries).toHaveBeenCalledTimes(1);
+ });
+
+ it("hides the Load More button in search results when loadMoreSearchResults is null", () => {
+ const { container } = render(
+ editor({
+ entries: makeEntries(),
+ isOwner: true,
+ searchResults: makeSearchResults(),
+ loadMoreSearchResults: null,
+ })
+ );
+
+ expect(
+ searchSection(container).querySelector(".load-more-button")
+ ).not.toBeInTheDocument();
+ });
+
+ it("hides the Load More button in search results when isFetchingSearchResults is true", () => {
+ const { container } = render(
+ editor({
+ entries: makeEntries(),
+ isOwner: true,
+ searchResults: makeSearchResults(),
+ isFetchingSearchResults: true,
+ })
+ );
+
+ expect(
+ searchSection(container).querySelector(".load-more-button")
+ ).not.toBeInTheDocument();
+ });
+
+ it("hides Load More button in list entries when loadMoreEntries is null", () => {
+ const { container } = render(
+ editor({
+ entries: makeEntries(),
+ isOwner: true,
+ searchResults: makeSearchResults(),
+ loadMoreEntries: null,
+ })
+ );
+
+ expect(
+ entriesSection(container).querySelector(".load-more-button")
+ ).not.toBeInTheDocument();
+ });
+
+ it("disables the Load More button when loading more search results", () => {
+ const { container, rerender } = render(
+ editor({
+ entries: makeEntries(),
+ isOwner: true,
+ searchResults: makeSearchResults(),
+ })
+ );
+
+ let button = searchSection(container).querySelector(".load-more-button");
+ expect(button).toBeInTheDocument();
+ expect(button).not.toBeDisabled();
+
+ rerender(
+ editor({
+ entries: makeEntries(),
+ isOwner: true,
+ searchResults: makeSearchResults(),
+ isFetchingMoreSearchResults: true,
+ })
+ );
+
+ button = searchSection(container).querySelector(".load-more-button");
+ expect(button).toBeInTheDocument();
+ expect(button).toBeDisabled();
+ });
+
+ it("disables the Load More button when loading more list entries", () => {
+ const { container, rerender } = render(
+ editor({
+ entries: makeEntries(),
+ isOwner: true,
+ })
+ );
+
+ let button = entriesSection(container).querySelector(".load-more-button");
+ expect(button).toBeInTheDocument();
+ expect(button).not.toBeDisabled();
+
+ rerender(
+ editor({
+ entries: makeEntries(),
+ isOwner: true,
+ isFetchingMoreCustomListEntries: true,
+ })
+ );
+
+ button = entriesSection(container).querySelector(".load-more-button");
+ expect(button).toBeInTheDocument();
+ expect(button).toBeDisabled();
+ });
+
+ it("calls loadMoreSearchResults when the Load More button is clicked in search results", async () => {
+ const user = userEvent.setup();
+ const { container } = render(
+ editor({
+ entries: makeEntries(),
+ isOwner: true,
+ searchResults: makeSearchResults(),
+ })
+ );
+
+ await user.click(
+ searchSection(container).querySelector(".load-more-button") as HTMLElement
+ );
+
+ expect(loadMoreSearchResults).toHaveBeenCalledTimes(1);
+ });
+
+ it("calls loadMoreEntries when the Load More button is clicked in list entries", async () => {
+ const user = userEvent.setup();
+ const { container } = render(
+ editor({
+ entries: makeEntries(),
+ isOwner: true,
+ })
+ );
+
+ await user.click(
+ entriesSection(container).querySelector(
+ ".load-more-button"
+ ) as HTMLElement
+ );
+
+ expect(loadMoreEntries).toHaveBeenCalledTimes(1);
+ });
+
+ it("should properly display the count of list entries", () => {
+ const searchResults = makeSearchResults();
+
+ const { container, rerender } = render(
+ editor({
+ entries: makeEntries(),
+ isOwner: true,
+ searchResults,
+ entryCount: 2,
+ })
+ );
+
+ expect(entriesHeadingText(container)).toBe(
+ "List Entries: Displaying 1 - 2 of 2 books"
+ );
+
+ rerender(
+ editor({
+ entries: makeEntries().slice(0, 1),
+ isOwner: true,
+ searchResults,
+ entryCount: 1,
+ })
+ );
+ expect(entriesHeadingText(container)).toBe(
+ "List Entries: Displaying 1 - 1 of 1 book"
+ );
+
+ rerender(
+ editor({
+ entries: [],
+ isOwner: true,
+ searchResults,
+ entryCount: 0,
+ })
+ );
+ expect(entriesHeadingText(container)).toBe(
+ "List Entries: No books in this list"
+ );
+
+ rerender(
+ editor({
+ entries: [],
+ isOwner: true,
+ searchResults,
+ entryCount: 12,
+ })
+ );
+ expect(entriesHeadingText(container)).toBe(
+ "List Entries: Displaying 0 - 0 of 12 books"
+ );
+
+ rerender(
+ editor({
+ entries: makeEntries(),
+ isOwner: true,
+ searchResults,
+ entryCount: 12,
+ })
+ );
+ expect(entriesHeadingText(container)).toBe(
+ "List Entries: Displaying 1 - 2 of 12 books"
+ );
+ });
+});
diff --git a/tests/jest/components/CustomLists.test.tsx b/tests/jest/components/CustomLists.test.tsx
index c19eeb177f..96c695fd56 100644
--- a/tests/jest/components/CustomLists.test.tsx
+++ b/tests/jest/components/CustomLists.test.tsx
@@ -1,12 +1,30 @@
import * as React from "react";
-import { screen, waitFor } from "@testing-library/react";
+import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { setupServer } from "msw/node";
import { http, HttpResponse } from "msw";
-import CustomLists from "../../../src/components/CustomLists";
+import CustomLists, {
+ CustomLists as UnconnectedCustomLists,
+} from "../../../src/components/CustomLists";
+import * as CustomListEditorModule from "../../../src/components/CustomListEditor";
+import * as navigate from "../../../src/utils/navigate";
import renderWithContext from "../testUtils/renderWithContext";
import buildStore from "../../../src/store";
-import { ConfigurationSettings } from "../../../src/interfaces";
+import { ConfigurationSettings, LaneData } from "../../../src/interfaces";
+import { defaultFeatureFlags } from "../../../src/utils/featureFlags";
+
+// Render react-router's Link as a marker div so the sidebar's edit/create
+// targets can be asserted via `data-to` without a Router in the test. Only
+// CustomListsSidebar uses Link in this tree (CustomListEditor does not), so the
+// existing connected tests are unaffected.
+jest.mock("react-router", () => ({
+ ...jest.requireActual("react-router"),
+ Link: (props) => (
+
+ {props.children}
+
+ ),
+}));
describe("CustomLists", () => {
// Stub scrollTo, since a component in the render tree will try to call it, and it is not
@@ -181,7 +199,7 @@ describe("CustomLists", () => {
appConfigSettings
);
- await userEvent.selectOptions(
+ await user.selectOptions(
screen.getByRole("combobox", { name: "filter field key" }),
screen.getByRole("option", { name: "Language" })
);
@@ -199,4 +217,603 @@ describe("CustomLists", () => {
});
});
});
+
+ // These render the unconnected component with explicit props, drive the real
+ // sidebar, and stub the heavy CustomListEditor child so the component's own
+ // render/lifecycle/methods are exercised observably. Nested inside the
+ // top-level describe so the MSW server and scrollTo stub apply.
+ describe("unconnected component with explicit props", () => {
+ const listsUrl = "/admin/web/lists/library";
+
+ const customListEditorProperties = {
+ name: "",
+ collections: [],
+ autoUpdate: false,
+ };
+ const customListEditorEntries = {
+ baseline: [],
+ baselineTotalCount: 0,
+ added: {},
+ removed: {},
+ current: [],
+ currentTotalCount: 0,
+ };
+ const customListEditorSearchParams = {
+ entryPoint: "All",
+ terms: "",
+ sort: null,
+ language: "all",
+ advanced: {
+ include: { query: null, selectedQueryId: null, clearFilters: null },
+ exclude: { query: null, selectedQueryId: null, clearFilters: null },
+ },
+ };
+
+ const listsData = [
+ {
+ id: 1,
+ name: "a list",
+ entry_count: 0,
+ collections: [],
+ is_owner: true,
+ is_shared: false,
+ },
+ {
+ id: 2,
+ name: "z list",
+ entry_count: 1,
+ collections: [{ id: 3, name: "collection 3", protocol: "protocol" }],
+ is_owner: true,
+ is_shared: false,
+ },
+ ];
+
+ const entry = { pwid: "1", title: "title", authors: [] };
+
+ const searchResults = {
+ id: "id",
+ url: "url",
+ title: "title",
+ lanes: [],
+ books: [],
+ navigationLinks: [],
+ nextPageUrl: "http://next.page",
+ };
+
+ const collections = [
+ {
+ id: 1,
+ name: "collection 1",
+ protocol: "protocol",
+ libraries: [{ short_name: "other library" }],
+ },
+ {
+ id: 2,
+ name: "collection 2",
+ protocol: "protocol",
+ libraries: [{ short_name: "library" }],
+ },
+ {
+ id: 3,
+ name: "collection 3",
+ protocol: "protocol",
+ libraries: [{ short_name: "library" }],
+ },
+ ];
+
+ const libraries = [
+ {
+ short_name: "library",
+ settings: { enabled_entry_points: ["Book", "Audio"] },
+ },
+ {
+ short_name: "another library",
+ settings: { enabled_entry_points: ["Audio"] },
+ },
+ ];
+
+ const languages = {
+ eng: ["English"],
+ spa: ["Spanish", "Castilian"],
+ fre: ["French"],
+ };
+
+ const lane1: LaneData = {
+ id: 1,
+ display_name: "lane 1",
+ visible: false,
+ count: 1,
+ sublanes: [],
+ custom_list_ids: [2],
+ inherit_parent_restrictions: false,
+ };
+ const lane2: LaneData = {
+ id: 2,
+ display_name: "lane 2",
+ visible: false,
+ count: 1,
+ sublanes: [],
+ custom_list_ids: [2],
+ inherit_parent_restrictions: false,
+ };
+ const lane3: LaneData = {
+ id: 3,
+ display_name: "lane 3",
+ visible: false,
+ count: 1,
+ sublanes: [],
+ custom_list_ids: [],
+ inherit_parent_restrictions: false,
+ };
+ const allLanes = [lane1, lane2, lane3];
+
+ // Test doubles for the dispatch props.
+ let fetchCustomLists: jest.Mock;
+ let fetchCustomListDetails: jest.Mock;
+ let deleteCustomList: jest.Mock;
+ let shareCustomList: jest.Mock;
+ let openCustomListEditor: jest.Mock;
+ let saveCustomListEditor: jest.Mock;
+ let executeCustomListEditorSearch: jest.Mock;
+ let loadMoreSearchResults: jest.Mock;
+ let loadMoreEntries: jest.Mock;
+ let fetchCollections: jest.Mock;
+ let fetchLibraries: jest.Mock;
+ let fetchLanes: jest.Mock;
+ let fetchLanguages: jest.Mock;
+ let navigateSpy: jest.SpyInstance;
+
+ const configWithRoles = (
+ roles: Partial["roles"]
+ ): Partial => ({
+ csrfToken: "token",
+ featureFlags: defaultFeatureFlags,
+ roles,
+ });
+
+ const managerConfig = configWithRoles([
+ { role: "manager", library: "library" },
+ ]);
+ const librarianConfig = configWithRoles([
+ { role: "librarian", library: "library" },
+ ]);
+
+ const baseCustomListsProps = (overrides: Record = {}) => ({
+ customListEditorProperties,
+ customListEditorEntries,
+ customListEditorSearchParams,
+ csrfToken: "token",
+ library: "library",
+ lists: listsData,
+ searchResults,
+ collections,
+ isFetching: false,
+ isFetchingSearchResults: false,
+ isFetchingMoreSearchResults: false,
+ isFetchingMoreCustomListEntries: false,
+ fetchLanguages,
+ fetchCustomLists,
+ fetchCustomListDetails,
+ saveCustomListEditor,
+ deleteCustomList,
+ shareCustomList,
+ executeCustomListEditorSearch,
+ openCustomListEditor,
+ loadMoreSearchResults,
+ loadMoreEntries,
+ fetchCollections,
+ fetchLibraries,
+ fetchLanes,
+ libraries,
+ languages,
+ ...overrides,
+ });
+
+ const renderCustomLists = (
+ overrides: Record = {},
+ config: Partial = managerConfig
+ ) =>
+ renderWithContext(
+ ,
+ config
+ );
+
+ const sidebarListNames = (container: HTMLElement) =>
+ Array.from(
+ container.querySelectorAll(
+ ".custom-lists-sidebar ul > li .custom-list-info > div:first-child"
+ )
+ // The name is the trailing text node; a ShareIcon (with title text) may
+ // precede it for lists that are not owned by this library.
+ ).map((el) => el.lastChild?.textContent);
+
+ const lastNavigation = () =>
+ navigateSpy.mock.calls[navigateSpy.mock.calls.length - 1]?.[0];
+
+ beforeEach(() => {
+ fetchCustomLists = jest.fn();
+ fetchCustomListDetails = jest.fn();
+ deleteCustomList = jest.fn().mockResolvedValue(undefined);
+ shareCustomList = jest.fn().mockResolvedValue(undefined);
+ openCustomListEditor = jest.fn();
+ saveCustomListEditor = jest.fn().mockResolvedValue(undefined);
+ executeCustomListEditorSearch = jest.fn();
+ loadMoreSearchResults = jest.fn();
+ loadMoreEntries = jest.fn();
+ fetchCollections = jest.fn();
+ fetchLibraries = jest.fn();
+ fetchLanes = jest.fn().mockResolvedValue(undefined);
+ fetchLanguages = jest.fn();
+
+ navigateSpy = jest
+ .spyOn(navigate, "navigateTo")
+ .mockImplementation(() => undefined);
+ jest.spyOn(navigate, "currentHref").mockReturnValue(listsUrl);
+
+ // Stub the heavy editor child so we can assert the props CustomLists
+ // hands it and drive its `save` callback without its full render tree.
+ jest
+ .spyOn(CustomListEditorModule, "default")
+ .mockImplementation((props: any) => (
+
+
+ {props.library ? props.library.short_name : "none"}
+
+ {props.listId ?? "none"}
+
+ {(props.collections || []).map((c) => c.name).join(",")}
+
+
+ {(props.entryPoints || []).join(",")}
+
+
+
+
+ ));
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ it("fetches libraries, languages, lists, and collections on mount", () => {
+ renderCustomLists();
+ expect(fetchLibraries).toHaveBeenCalledTimes(1);
+ expect(fetchLanguages).toHaveBeenCalledTimes(1);
+ expect(fetchCustomLists).toHaveBeenCalledTimes(1);
+ expect(fetchCollections).toHaveBeenCalledTimes(1);
+ });
+
+ it("renders an error message when there is a fetch error", () => {
+ const { container, rerender } = renderCustomLists();
+ expect(container.querySelector(".alert-danger")).toBeNull();
+
+ rerender(
+
+ );
+ expect(container.querySelector(".alert-danger")).not.toBeNull();
+ });
+
+ it("renders a loading indicator while fetching", () => {
+ const notLoading = renderCustomLists();
+ expect(notLoading.queryByRole("dialog")).toBeNull();
+ notLoading.unmount();
+
+ const loading = renderCustomLists({ isFetching: true });
+ expect(loading.getByRole("dialog")).toBeInTheDocument();
+ });
+
+ it("navigates to the create or edit page on initial load", () => {
+ // No lists → create page.
+ const first = renderCustomLists({ lists: undefined });
+ first.rerender(
+
+ );
+ expect(lastNavigation()).toEqual(`${listsUrl}/create`);
+ first.unmount();
+
+ // Owned lists → edit page for the first sorted list.
+ const second = renderCustomLists({ lists: undefined });
+ second.rerender(
+
+ );
+ expect(lastNavigation()).toEqual(`${listsUrl}/edit/1`);
+ second.unmount();
+
+ // Lists that are not owned → create page (filtered out by the "owned" filter).
+ const noOwnedListsData = [
+ {
+ id: 1,
+ name: "a list",
+ entry_count: 0,
+ collections: [],
+ is_owner: false,
+ is_shared: true,
+ },
+ ];
+ const third = renderCustomLists({ lists: undefined });
+ third.rerender(
+
+ );
+ expect(lastNavigation()).toEqual(`${listsUrl}/create`);
+ });
+
+ it("sorts the lists when the sort order changes", async () => {
+ const user = userEvent.setup();
+ const { container } = renderCustomLists();
+ expect(sidebarListNames(container)).toEqual(["a list", "z list"]);
+
+ const sortSelect = container.querySelector(
+ 'select[name="sort"]'
+ );
+ await user.selectOptions(sortSelect, "desc");
+ expect(sidebarListNames(container)).toEqual(["z list", "a list"]);
+
+ await user.selectOptions(sortSelect, "asc");
+ expect(sidebarListNames(container)).toEqual(["a list", "z list"]);
+ });
+
+ it("filters the lists when the filter changes", async () => {
+ const user = userEvent.setup();
+ const sharedListsData = [
+ {
+ id: 1,
+ name: "owned unshared",
+ entry_count: 0,
+ collections: [],
+ is_owner: true,
+ is_shared: false,
+ },
+ {
+ id: 2,
+ name: "owned shared",
+ entry_count: 1,
+ collections: [],
+ is_owner: true,
+ is_shared: true,
+ },
+ {
+ id: 3,
+ name: "not owned",
+ entry_count: 1,
+ collections: [],
+ is_owner: false,
+ is_shared: true,
+ },
+ ];
+ const { container } = renderCustomLists({ lists: sharedListsData });
+ const filterSelect = container.querySelector(
+ 'select[name="filter"]'
+ );
+
+ // Default "owned" filter.
+ expect(sidebarListNames(container)).toEqual([
+ "owned shared",
+ "owned unshared",
+ ]);
+
+ await user.selectOptions(filterSelect, "shared-in");
+ expect(sidebarListNames(container)).toEqual(["not owned"]);
+
+ await user.selectOptions(filterSelect, "shared-out");
+ expect(sidebarListNames(container)).toEqual(["owned shared"]);
+
+ await user.selectOptions(filterSelect, "");
+ expect(sidebarListNames(container)).toEqual([
+ "not owned",
+ "owned shared",
+ "owned unshared",
+ ]);
+
+ await user.selectOptions(filterSelect, "owned");
+ expect(sidebarListNames(container)).toEqual([
+ "owned shared",
+ "owned unshared",
+ ]);
+ });
+
+ it("renders edit links but no delete buttons for a librarian", () => {
+ const { container } = renderCustomLists({}, librarianConfig);
+ const items = container.querySelectorAll(".custom-lists-sidebar ul > li");
+ expect(items).toHaveLength(2);
+
+ const firstButtons = items[0].querySelector(
+ ".custom-list-buttons"
+ );
+ const firstEdit = within(firstButtons).getByTestId("Link");
+ expect(firstEdit).toHaveTextContent("Edit");
+ expect(firstEdit).toHaveAttribute(
+ "data-to",
+ "/admin/web/lists/library/edit/1"
+ );
+
+ const secondButtons = items[1].querySelector(
+ ".custom-list-buttons"
+ );
+ const secondEdit = within(secondButtons).getByTestId("Link");
+ expect(secondEdit).toHaveAttribute(
+ "data-to",
+ "/admin/web/lists/library/edit/2"
+ );
+
+ expect(
+ container.querySelectorAll(".custom-list-buttons button")
+ ).toHaveLength(0);
+ });
+
+ it("deletes a list only after the user confirms, fetching lanes first", async () => {
+ const user = userEvent.setup();
+ const confirmSpy = jest.spyOn(window, "confirm").mockReturnValue(false);
+ const { container } = renderCustomLists();
+
+ const deleteButton = () =>
+ container.querySelectorAll(
+ ".custom-list-buttons button"
+ )[0];
+
+ await user.click(deleteButton());
+ // No lanes were supplied, so the component fetched them.
+ await waitFor(() => expect(fetchLanes).toHaveBeenCalled());
+ expect(deleteCustomList).toHaveBeenCalledTimes(0);
+
+ confirmSpy.mockReturnValue(true);
+ await user.click(deleteButton());
+ await waitFor(() => expect(deleteCustomList).toHaveBeenCalledTimes(1));
+ expect(deleteCustomList).toHaveBeenCalledWith("1");
+ await waitFor(() => expect(fetchCustomLists).toHaveBeenCalledTimes(2));
+ });
+
+ it("warns which lanes will be deleted along with the list", async () => {
+ const user = userEvent.setup();
+ const confirmSpy = jest.spyOn(window, "confirm").mockReturnValue(true);
+ const { container } = renderCustomLists({ lanes: allLanes });
+
+ const deleteButtons = () =>
+ container.querySelectorAll(
+ ".custom-list-buttons button"
+ );
+
+ // "a list" (id 1) has no lanes referencing it exclusively.
+ await user.click(deleteButtons()[0]);
+ await waitFor(() => expect(confirmSpy).toHaveBeenCalledTimes(1));
+ expect(confirmSpy.mock.calls[0][0]).toEqual('Delete list "a list"? ');
+
+ // "z list" (id 2) is the only list in lane 1 and lane 2.
+ await user.click(deleteButtons()[1]);
+ await waitFor(() => expect(confirmSpy).toHaveBeenCalledTimes(2));
+ expect(confirmSpy.mock.calls[1][0]).toEqual(
+ 'Delete list "z list"? ' +
+ "Deleting this list will delete the following lanes:\n" +
+ "\nLane name: lane 1\nLane name: lane 2"
+ );
+ // The lanes were supplied as a prop, so no fetch was needed.
+ expect(fetchLanes).not.toHaveBeenCalled();
+ });
+
+ it("renders the create form, passing the right library and collections, and saves", async () => {
+ const user = userEvent.setup();
+ renderCustomLists({ editOrCreate: "create" });
+
+ expect(screen.getByTestId("editor-library")).toHaveTextContent("library");
+ expect(screen.getByTestId("editor-listid")).toHaveTextContent("none");
+ expect(screen.getByTestId("editor-collections")).toHaveTextContent(
+ "collection 2,collection 3"
+ );
+ expect(fetchCustomLists).toHaveBeenCalledTimes(1);
+
+ await user.click(screen.getByTestId("editor-save"));
+ expect(saveCustomListEditor).toHaveBeenCalledTimes(1);
+ await waitFor(() => expect(fetchCustomLists).toHaveBeenCalledTimes(2));
+ });
+
+ it("renders the edit form and fetches details when the selected list changes", () => {
+ const listDetails = Object.assign({}, listsData[1], { entries: [entry] });
+ const { rerender } = renderCustomLists({
+ editOrCreate: "edit",
+ identifier: "2",
+ listDetails,
+ });
+
+ expect(screen.getByTestId("editor-listid")).toHaveTextContent("2");
+ expect(screen.getByTestId("editor-library")).toHaveTextContent("library");
+ expect(fetchCustomListDetails).toHaveBeenCalledTimes(1);
+
+ rerender(
+
+ );
+ expect(fetchCustomListDetails).toHaveBeenCalledTimes(2);
+ expect(screen.getByTestId("editor-listid")).toHaveTextContent("1");
+ });
+
+ it("shares the current list through the editor's share action", async () => {
+ const user = userEvent.setup();
+ renderCustomLists({ editOrCreate: "edit", identifier: "2" });
+ await user.click(screen.getByTestId("editor-share"));
+ expect(shareCustomList).toHaveBeenCalledTimes(1);
+ expect(shareCustomList).toHaveBeenCalledWith("2");
+ });
+
+ it("refetches the list details after saving in edit mode", async () => {
+ const user = userEvent.setup();
+ renderCustomLists({ editOrCreate: "edit", identifier: "2" });
+ expect(fetchCustomListDetails).toHaveBeenCalledTimes(1);
+
+ await user.click(screen.getByTestId("editor-save"));
+ expect(saveCustomListEditor).toHaveBeenCalledTimes(1);
+ await waitFor(() =>
+ expect(fetchCustomListDetails).toHaveBeenCalledTimes(2)
+ );
+ });
+
+ it("keeps lists with identical names in a stable order", () => {
+ const sameNameLists = [
+ {
+ id: 1,
+ name: "same name",
+ entry_count: 0,
+ collections: [],
+ is_owner: true,
+ is_shared: false,
+ },
+ {
+ id: 2,
+ name: "same name",
+ entry_count: 0,
+ collections: [],
+ is_owner: true,
+ is_shared: false,
+ },
+ ];
+ const { container } = renderCustomLists({ lists: sameNameLists });
+ expect(
+ container.querySelectorAll(".custom-lists-sidebar ul > li")
+ ).toHaveLength(2);
+ });
+
+ it("gets the enabled entry points for the current library", () => {
+ const forLibrary = renderCustomLists({ editOrCreate: "create" });
+ expect(forLibrary.getByTestId("editor-entrypoints")).toHaveTextContent(
+ "Book,Audio"
+ );
+ forLibrary.unmount();
+
+ const forAnotherLibrary = renderCustomLists(
+ { editOrCreate: "create", library: "another library" },
+ librarianConfig
+ );
+ expect(
+ forAnotherLibrary.getByTestId("editor-entrypoints")
+ ).toHaveTextContent("Audio");
+ });
+ });
});
diff --git a/tests/jest/components/InputList.test.tsx b/tests/jest/components/InputList.test.tsx
new file mode 100644
index 0000000000..221850c39d
--- /dev/null
+++ b/tests/jest/components/InputList.test.tsx
@@ -0,0 +1,493 @@
+import * as React from "react";
+import { render, act, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+
+import ProtocolFormField from "../../../src/components/ProtocolFormField";
+
+// InputList is always rendered by ProtocolFormField, which supplies the real
+// `createEditableInput` and `labelAndDescription` callbacks it depends on.
+// Rendering through ProtocolFormField exercises that wiring while letting us
+// assert the DOM the InputList actually produces. The imperative `getValue()` /
+// `clear()` API is exposed through ProtocolFormField's ref, which is how
+// production calls it.
+
+const setting = {
+ key: "setting",
+ label: "label",
+ description: "description",
+ type: "list",
+};
+const value = ["Thing 1", "Thing 2"];
+
+const renderList = (
+ { setting: s = setting, value: v = value, ...rest }: any = {},
+ ref?: React.Ref
+) =>
+ render(
+
+ );
+
+// The list-item inputs live in the `ul.input-list-ul`; the "add" input lives in
+// `.add-list-item-container`.
+const itemInputs = (container: HTMLElement) =>
+ Array.from(
+ container.querySelectorAll(
+ "ul.input-list-ul input.form-control"
+ )
+ );
+const addInput = (container: HTMLElement) =>
+ container.querySelector(
+ ".add-list-item-container span.add-list-item input"
+ );
+const addButton = (container: HTMLElement) =>
+ container.querySelector("button.add-list-item");
+const removeButtons = (container: HTMLElement) =>
+ container.querySelectorAll("button.remove-btn");
+const listItemWrappers = (container: HTMLElement) =>
+ container.querySelectorAll(".with-remove-button");
+
+describe("InputList", () => {
+ it("renders a label and description", () => {
+ const { container } = renderList();
+ const labels = container.querySelectorAll("label");
+ expect(labels).toHaveLength(1);
+ expect(labels[0]).toHaveTextContent("label");
+
+ // The first `.description` is the one produced by labelAndDescription.
+ const description = container.querySelectorAll(".description")[0];
+ expect(description).toHaveTextContent("description");
+ });
+
+ it("renders a list of items", () => {
+ const { container } = renderList();
+ const inputs =
+ container.querySelectorAll("input.form-control");
+ // Two existing items plus the empty "add" input.
+ expect(inputs).toHaveLength(3);
+
+ expect(inputs[0]).toHaveValue("Thing 1");
+ expect(inputs[0]).toHaveAttribute("type", "text");
+ expect(inputs[0]).toHaveAttribute("name", "setting");
+
+ expect(inputs[1]).toHaveValue("Thing 2");
+ expect(inputs[1]).toHaveAttribute("type", "text");
+ expect(inputs[1]).toHaveAttribute("name", "setting");
+
+ expect(inputs[2]).toHaveValue("");
+ expect(inputs[2]).toHaveAttribute("type", "text");
+ expect(inputs[2]).toHaveAttribute("name", "setting");
+ });
+
+ it("optionally renders links", () => {
+ const urlBase = (itemName: string) => `admin/web/${itemName}`;
+ const { container } = renderList({ setting: { ...setting, urlBase } });
+ const links = container.querySelectorAll("a");
+ expect(links).toHaveLength(2);
+ links.forEach((link, idx) => {
+ expect(link).toHaveTextContent(`Thing ${idx + 1}`);
+ expect(link).toHaveAttribute("href", `admin/web/Thing ${idx + 1}`);
+ });
+ });
+
+ it("renders WithRemoveButton elements", () => {
+ const { container } = renderList();
+ const wrappers = listItemWrappers(container);
+ expect(wrappers).toHaveLength(2);
+ const removes = removeButtons(container);
+ expect(removes).toHaveLength(2);
+ removes.forEach((btn) => expect(btn).not.toBeDisabled());
+
+ const inputs = itemInputs(container);
+ expect(inputs[0]).toHaveValue("Thing 1");
+ expect(inputs[1]).toHaveValue("Thing 2");
+ });
+
+ it("optionally disables the remove buttons", () => {
+ const { container } = renderList({ disableButton: true });
+ const removes = removeButtons(container);
+ expect(removes).toHaveLength(2);
+ removes.forEach((btn) => expect(btn).toBeDisabled());
+ });
+
+ it("renders a button for adding a list item", () => {
+ const { container } = renderList();
+ const addContainer = container.querySelector(".add-list-item-container");
+ expect(addContainer).toBeInTheDocument();
+ expect(addInput(container)).toHaveValue("");
+ // The add container itself has no remove buttons.
+ expect(addContainer.querySelectorAll(".with-remove-button")).toHaveLength(
+ 0
+ );
+ expect(addButton(container)).toHaveTextContent("Add");
+ });
+
+ it("optionally renders a geographic tooltip with extra content", () => {
+ const valueWithObject = [{ "Thing 3": "extra information!" }];
+ const geographicSetting = { ...setting, format: "geographic" };
+ const { container } = renderList({
+ setting: geographicSetting,
+ value: valueWithObject,
+ });
+
+ const withAddOn = container.querySelectorAll(".with-add-on");
+ expect(withAddOn).toHaveLength(1);
+
+ const addOn = withAddOn[0].querySelectorAll(".input-group-addon");
+ expect(addOn).toHaveLength(1);
+
+ expect(container.querySelector("svg.locatorIcon")).toBeInTheDocument();
+
+ const toolTip = container.querySelector(".tool-tip");
+ expect(toolTip).toHaveClass("point-right");
+ expect(toolTip).toHaveTextContent("extra information!");
+
+ const input = withAddOn[0].querySelector("input");
+ expect(input).toHaveValue("Thing 3");
+ });
+
+ it("renders an autocomplete field for languages", () => {
+ const languageSetting = { ...setting, format: "language-code" };
+ const languages = {
+ eng: ["English"],
+ spa: ["Spanish", "Castilian"],
+ };
+ const { container } = renderList({
+ setting: languageSetting,
+ value: ["abc"],
+ additionalData: languages,
+ });
+ const languageInputs = container.querySelectorAll(
+ 'input[list="setting-autocomplete-list"]'
+ );
+ // One for the existing "abc" item, one for the empty "add" field.
+ expect(languageInputs).toHaveLength(2);
+
+ expect(languageInputs[0]).toHaveValue("abc");
+ expect(languageInputs[0]).toHaveAttribute("name", "setting");
+
+ expect(languageInputs[1]).toHaveValue("");
+ expect(languageInputs[1]).toHaveAttribute("name", "setting");
+ });
+
+ it("removes an item", async () => {
+ const user = userEvent.setup();
+ const { container } = renderList();
+ expect(listItemWrappers(container)).toHaveLength(2);
+
+ await user.click(removeButtons(container)[0]);
+
+ await waitFor(() => expect(listItemWrappers(container)).toHaveLength(1));
+ });
+
+ it("adds a regular item", async () => {
+ const user = userEvent.setup();
+ const { container } = renderList();
+ expect(listItemWrappers(container)).toHaveLength(2);
+
+ await user.type(addInput(container), "Another thing...");
+ await user.click(addButton(container));
+
+ await waitFor(() => expect(listItemWrappers(container)).toHaveLength(3));
+ expect(addInput(container)).toHaveValue("");
+ });
+
+ it("adds and capitalizes an item", async () => {
+ const user = userEvent.setup();
+ // capitalize only runs when the setting has capitalize set; the geographic
+ // format is what makes two-letter tokens uppercase.
+ const capSetting = { ...setting, capitalize: true, format: "geographic" };
+ const { container } = renderList({ setting: capSetting, value: [] });
+
+ await user.type(addInput(container), "california");
+ await user.click(addButton(container));
+ await waitFor(() =>
+ expect(itemInputs(container)[0]).toHaveValue("California")
+ );
+
+ await user.type(addInput(container), "fl");
+ await user.click(addButton(container));
+ await waitFor(() => expect(itemInputs(container)[1]).toHaveValue("FL"));
+
+ await user.type(addInput(container), "new york city, ny");
+ await user.click(addButton(container));
+ await waitFor(() =>
+ expect(itemInputs(container)[2]).toHaveValue("New York City, NY")
+ );
+
+ expect(addInput(container)).toHaveValue("");
+ });
+
+ it("adds an autocompleted item", async () => {
+ const user = userEvent.setup();
+ const languageSetting = { ...setting, format: "language-code" };
+ const { container } = renderList({
+ setting: languageSetting,
+ value: ["abc"],
+ });
+
+ let autocompletes = container.querySelectorAll(
+ 'input[list="setting-autocomplete-list"]'
+ );
+ expect(autocompletes).toHaveLength(2);
+
+ await user.type(autocompletes[1], "Another language");
+ await user.click(addButton(container));
+
+ await waitFor(() => {
+ autocompletes = container.querySelectorAll(
+ 'input[list="setting-autocomplete-list"]'
+ );
+ expect(autocompletes).toHaveLength(3);
+ });
+ expect(autocompletes[2]).toHaveValue("");
+ });
+
+ it("does not add an empty input item", async () => {
+ const user = userEvent.setup();
+ const { container } = renderList();
+ expect(listItemWrappers(container)).toHaveLength(2);
+ // The add button is disabled while the input is blank.
+ expect(addButton(container)).toBeDisabled();
+
+ await user.type(addInput(container), "something new");
+ expect(addButton(container)).not.toBeDisabled();
+
+ await user.click(addButton(container));
+ await waitFor(() => expect(listItemWrappers(container)).toHaveLength(3));
+ // Once added, the input is cleared and the button is disabled again.
+ expect(addButton(container)).toBeDisabled();
+ });
+
+ it("optionally accepts an onChange prop", async () => {
+ const user = userEvent.setup();
+ const onChange = jest.fn();
+ const { container } = renderList({ onChange });
+ expect(onChange).not.toHaveBeenCalled();
+
+ await user.type(addInput(container), "test");
+ await user.click(addButton(container));
+ await waitFor(() => expect(onChange).toHaveBeenCalledTimes(1));
+ expect(onChange.mock.calls[0][0]).toHaveProperty("listItems");
+
+ await user.click(removeButtons(container)[0]);
+ await waitFor(() => expect(onChange).toHaveBeenCalledTimes(2));
+ expect(onChange.mock.calls[1][0]).toHaveProperty("listItems");
+ });
+
+ it("optionally accepts a readOnly prop", () => {
+ const { container } = renderList({ readOnly: true });
+ const inputs = itemInputs(container);
+ expect(inputs[0]).toHaveAttribute("readonly");
+ expect(inputs[1]).toHaveAttribute("readonly");
+ });
+
+ it("gets the value", () => {
+ const ref = React.createRef();
+ renderList({}, ref);
+ expect(ref.current!.getValue()).toStrictEqual(value);
+ });
+
+ it("clears all the items", () => {
+ const ref = React.createRef();
+ const { container } = renderList({}, ref);
+ expect(listItemWrappers(container)).toHaveLength(2);
+
+ act(() => {
+ ref.current!.clear();
+ });
+
+ expect(listItemWrappers(container)).toHaveLength(0);
+ });
+
+ describe("dropdown menu", () => {
+ const menuOptions = [1, 2, 3].map((n) => (
+
+ ));
+ const menuSetting = {
+ ...setting,
+ type: "menu",
+ menuOptions,
+ description: null,
+ };
+
+ it("renders a dropdown menu", () => {
+ const { container } = renderList({ setting: menuSetting });
+ const menu = container.querySelectorAll("select");
+ expect(menu).toHaveLength(1);
+ const options = menu[0].querySelectorAll("option");
+ expect(options).toHaveLength(3);
+ options.forEach((option, idx) => {
+ expect(option).toHaveTextContent(`Option ${idx + 1}`);
+ });
+ });
+
+ it("adds an item from the menu", async () => {
+ const user = userEvent.setup();
+ const { container } = renderList({ setting: menuSetting });
+ expect(listItemWrappers(container)).toHaveLength(2);
+ // The select defaults to the first option.
+ expect(container.querySelector("select")).toHaveValue("Option 1");
+
+ await user.click(addButton(container));
+
+ await waitFor(() => expect(listItemWrappers(container)).toHaveLength(3));
+ const inputs = itemInputs(container);
+ expect(inputs[inputs.length - 1]).toHaveValue("Option 1");
+ });
+
+ it("doesn't lose options when nothing is selected", () => {
+ const narrowSetting = {
+ ...menuSetting,
+ default: null,
+ format: "narrow",
+ };
+ const { container } = renderList({
+ setting: narrowSetting,
+ value: null,
+ });
+ expect(container.querySelectorAll("option")).toHaveLength(3);
+ });
+
+ it("optionally eliminates already-selected options from the menu", async () => {
+ const user = userEvent.setup();
+ const { container, rerender } = renderList({ setting: menuSetting });
+ let options = container.querySelectorAll("option");
+ expect(options).toHaveLength(3);
+ expect(
+ Array.from(options).some((o) => o.textContent === "Option 1")
+ ).toBe(true);
+
+ // Adding an option while the format is not "narrow" leaves the menu intact.
+ await user.click(addButton(container));
+ await waitFor(() => expect(listItemWrappers(container)).toHaveLength(3));
+ options = container.querySelectorAll("option");
+ expect(options).toHaveLength(3);
+
+ // Switch to the "narrow" format, which prunes selected options.
+ const narrowSetting = { ...menuSetting, format: "narrow" };
+ rerender(
+
+ );
+
+ await user.click(addButton(container));
+ await waitFor(() => {
+ options = container.querySelectorAll("option");
+ expect(options).toHaveLength(2);
+ });
+ expect(
+ Array.from(container.querySelectorAll("option")).some(
+ (o) => o.textContent === "Option 1"
+ )
+ ).toBe(false);
+ });
+
+ it("optionally renders a label", () => {
+ const settingWithLabel = {
+ ...menuSetting,
+ menuTitle: "Custom Menu Title",
+ };
+ const { container } = renderList({ setting: settingWithLabel });
+ const label = container.querySelector("select").closest("label");
+ expect(label).toHaveTextContent("Custom Menu Title");
+ });
+
+ it("optionally marks the menu as required", () => {
+ const { container, rerender } = renderList({ setting: menuSetting });
+ expect(container.querySelectorAll(".required-field")).toHaveLength(0);
+
+ const requiredSetting = {
+ ...menuSetting,
+ menuTitle: "title",
+ required: true,
+ };
+ rerender(
+
+ );
+ const required = container.querySelectorAll(".required-field");
+ expect(required).toHaveLength(1);
+ expect(required[0]).toHaveTextContent("Required");
+ });
+
+ it("optionally renders an alternate value if there are no list items", () => {
+ const { container, rerender } = renderList({
+ setting: menuSetting,
+ altValue: "No list items!",
+ });
+ // There are still list items, so the placeholder isn't rendered yet.
+ expect(container.querySelectorAll(".input-list > span")).toHaveLength(0);
+
+ rerender(
+
+ );
+ const placeholder = container.querySelectorAll(".input-list > span");
+ expect(placeholder).toHaveLength(1);
+ expect(placeholder[0]).toHaveTextContent("No list items!");
+ });
+
+ it("optionally renders an alternate value if all the available list items have already been added", () => {
+ const narrowSetting = { ...menuSetting, format: "narrow" };
+ const { container } = renderList({
+ setting: narrowSetting,
+ value: ["Option 1", "Option 2", "Option 3"],
+ onEmpty: "You've run out of options!",
+ });
+ expect(container.querySelector("select")).not.toBeInTheDocument();
+ const message = container.querySelector(".add-list-item-container span");
+ expect(message).toHaveTextContent("You've run out of options!");
+ });
+
+ it("renders option elements if necessary", () => {
+ // Without a menuOptions property, InputList builds the menu from `options`.
+ const options = [
+ { key: "key_1", label: "label_1" },
+ { key: "key_2", label: "label_2" },
+ ];
+ const optionsSetting = { ...menuSetting, options, menuOptions: null };
+ const { container } = renderList({ setting: optionsSetting });
+ const select = container.querySelector("select");
+ expect(select).toBeInTheDocument();
+ const opts = select.querySelectorAll("option");
+ expect(opts[0]).toHaveValue("key_1");
+ expect(opts[0]).toHaveTextContent("label_1");
+ expect(opts[1]).toHaveValue("key_2");
+ expect(opts[1]).toHaveTextContent("label_2");
+ });
+
+ it("renders the default values", () => {
+ const defaultSetting = {
+ ...menuSetting,
+ default: ["Option 1", "Option 2"],
+ };
+ const { container } = renderList({
+ setting: defaultSetting,
+ value: null,
+ });
+ const inputs = itemInputs(container);
+ expect(inputs[0]).toHaveValue("Option 1");
+ expect(inputs[1]).toHaveValue("Option 2");
+ // The remaining option is still available in the menu.
+ expect(container.querySelector("select")).toBeInTheDocument();
+ });
+ });
+});
diff --git a/tests/jest/components/LaneCustomListsEditor.test.tsx b/tests/jest/components/LaneCustomListsEditor.test.tsx
new file mode 100644
index 0000000000..3667f8891f
--- /dev/null
+++ b/tests/jest/components/LaneCustomListsEditor.test.tsx
@@ -0,0 +1,450 @@
+import * as React from "react";
+import { render, screen, within, act } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+
+// Capture the component's onDragStart/onDragEnd while still rendering the real
+// render-prop DOM (so the button/column tests below are unaffected). A real drag
+// can't run in jsdom, so the drag-handler tests call the captured callbacks
+// directly.
+let dndCallbacks: { onDragStart?: any; onDragEnd?: any } = {};
+jest.mock("react-beautiful-dnd", () => ({
+ __esModule: true,
+ DragDropContext: ({ onDragStart, onDragEnd, children }: any) => {
+ dndCallbacks = { onDragStart, onDragEnd };
+ return children;
+ },
+ Droppable: ({ droppableId, isDropDisabled, children }: any) => (
+
+ {children(
+ { droppableProps: {}, innerRef: () => undefined, placeholder: null },
+ { isDraggingOver: false }
+ )}
+
+ ),
+ Draggable: ({ children }: any) =>
+ children(
+ {
+ draggableProps: {},
+ dragHandleProps: {},
+ innerRef: () => undefined,
+ draggableStyle: {},
+ placeholder: null,
+ },
+ { isDragging: false }
+ ),
+}));
+
+import LaneCustomListsEditor from "../../../src/components/LaneCustomListsEditor";
+import { CustomListData } from "../../../src/interfaces";
+
+// NOTE ON DRAG-AND-DROP COVERAGE
+// ------------------------------
+// This component uses `react-beautiful-dnd`. A real drag/drop cannot be
+// simulated in jsdom, so the drag paths are exercised by invoking the captured
+// onDragStart/onDragEnd callbacks and asserting the observable result:
+// - Adding a list (drop from available to current) and removing one (drop from
+// current to available) update the selection; both are also reachable via the
+// "Add to lane" / "Remove from lane" buttons and are tested below.
+// - onDragStart toggles each Droppable's `isDropDisabled` (surfaced by the test
+// mock as `data-drop-disabled`) and, when dragging from the current lists,
+// shows the "remove them from the lane" message over the available lists.
+// - `reset()` / `getCustomListIds()` form the imperative API that LaneEditor
+// drives through a ref in production; they are tested through a ref below.
+
+const allCustomListsData: CustomListData[] = [
+ { id: 1, name: "list 1", entry_count: 0, is_owner: true, is_shared: false },
+ { id: 2, name: "list 2", entry_count: 2, is_owner: true, is_shared: false },
+ { id: 3, name: "list 3", entry_count: 0, is_owner: true, is_shared: false },
+];
+
+const sharedList: CustomListData = {
+ id: 4,
+ name: "list 4",
+ entry_count: 0,
+ is_owner: false,
+ is_shared: true,
+};
+
+const shareTitle = /shared by another library/i;
+
+const availableLists = (container: HTMLElement) =>
+ Array.from(
+ container.querySelectorAll(".available-lists .available-list")
+ );
+const currentLists = (container: HTMLElement) =>
+ Array.from(
+ container.querySelectorAll(".current-lists .current-list")
+ );
+
+describe("LaneCustomListsEditor", () => {
+ it("renders available lists", () => {
+ const filteredCustomListsData = [
+ allCustomListsData[0],
+ allCustomListsData[2],
+ ];
+
+ const { container, rerender } = render(
+
+ );
+
+ expect(container.querySelector(".available-lists")).toBeInTheDocument();
+
+ let lists = availableLists(container);
+ expect(lists).toHaveLength(2);
+ expect(lists[0]).toHaveTextContent("list 1");
+ expect(lists[0]).toHaveTextContent("Items in list: 0");
+ expect(lists[1]).toHaveTextContent("list 3");
+ expect(lists[1]).toHaveTextContent("Items in list: 0");
+
+ // With list 1 already in the lane, it drops out of the available column.
+ rerender(
+
+ );
+
+ lists = availableLists(container);
+ expect(lists).toHaveLength(1);
+ expect(lists[0]).toHaveTextContent("list 3");
+ expect(lists[0]).toHaveTextContent("Items in list: 0");
+ });
+
+ it("renders a share icon on available lists that are not owned by the current library", () => {
+ const sharedCustomListsData = [...allCustomListsData, sharedList];
+
+ const { container } = render(
+
+ );
+
+ const lists = availableLists(container);
+ expect(lists).toHaveLength(4);
+
+ expect(within(lists[0]).queryByTitle(shareTitle)).toBeNull();
+ expect(within(lists[1]).queryByTitle(shareTitle)).toBeNull();
+ expect(within(lists[2]).queryByTitle(shareTitle)).toBeNull();
+ expect(within(lists[3]).queryByTitle(shareTitle)).not.toBeNull();
+ });
+
+ it("renders filter select", async () => {
+ const user = userEvent.setup();
+ const changeFilter = jest.fn();
+
+ const { container } = render(
+
+ );
+
+ const select = container.querySelector(
+ 'select[name="filter"]'
+ );
+ expect(select).toHaveValue("owned");
+
+ const options = within(select).getAllByRole("option");
+ expect(options).toHaveLength(3);
+ expect(options[0]).toHaveValue("");
+ expect(options[1]).toHaveValue("owned");
+ expect(options[2]).toHaveValue("shared-in");
+
+ await user.selectOptions(select, "shared-in");
+
+ expect(changeFilter).toHaveBeenCalledTimes(1);
+ expect(changeFilter).toHaveBeenCalledWith("shared-in");
+ });
+
+ it("renders current lists", () => {
+ const { container, rerender } = render(
+
+ );
+
+ expect(container.querySelector(".current-lists")).toBeInTheDocument();
+ expect(currentLists(container)).toHaveLength(0);
+
+ rerender(
+
+ );
+
+ const lists = currentLists(container);
+ expect(lists).toHaveLength(2);
+ expect(lists[0]).toHaveTextContent("list 2");
+ expect(lists[0]).toHaveTextContent("Items in list: 2");
+ expect(lists[1]).toHaveTextContent("list 3");
+ expect(lists[1]).toHaveTextContent("Items in list: 0");
+ });
+
+ it("renders a share icon on current lists that are not owned by the current library", () => {
+ const sharedCustomListsData = [...allCustomListsData, sharedList];
+
+ const { container } = render(
+
+ );
+
+ const lists = currentLists(container);
+ expect(lists).toHaveLength(2);
+
+ expect(within(lists[0]).queryByTitle(shareTitle)).toBeNull();
+ expect(within(lists[1]).queryByTitle(shareTitle)).not.toBeNull();
+ });
+
+ it("adds a list to the lane when the add button is clicked", async () => {
+ const user = userEvent.setup();
+ const onUpdate = jest.fn();
+
+ const { container } = render(
+
+ );
+
+ // Available column starts with list 1 and list 3 (list 2 is in the lane).
+ const available = container.querySelector(".available-lists");
+ const addButtons = within(available).getAllByRole("button", {
+ name: /add to lane/i,
+ });
+ expect(addButtons).toHaveLength(2);
+
+ // Add list 1 (the first available list).
+ await user.click(addButtons[0]);
+
+ expect(onUpdate).toHaveBeenCalledTimes(1);
+ expect(onUpdate.mock.calls[0][0]).toEqual(expect.arrayContaining([1, 2]));
+
+ // The item now appears in the current lists.
+ const nowCurrent = currentLists(container);
+ expect(nowCurrent).toHaveLength(2);
+ expect(nowCurrent[0]).toHaveTextContent("list 1");
+ expect(nowCurrent[1]).toHaveTextContent("list 2");
+ });
+
+ it("removes a list from the lane when the remove button is clicked", async () => {
+ const user = userEvent.setup();
+ const onUpdate = jest.fn();
+
+ const { container, rerender } = render(
+
+ );
+
+ const current = container.querySelector(".current-lists");
+ const removeButtons = within(current).getAllByRole("button", {
+ name: /remove from lane/i,
+ });
+ expect(removeButtons).toHaveLength(2);
+
+ // Remove list 1 (the first current list).
+ await user.click(removeButtons[0]);
+
+ expect(onUpdate).toHaveBeenCalledTimes(1);
+ expect(onUpdate.mock.calls[0][0]).toEqual([2]);
+
+ // Reflect the parent's updated ids; the removed list is gone.
+ rerender(
+
+ );
+
+ const nowCurrent = currentLists(container);
+ expect(nowCurrent).toHaveLength(1);
+ expect(nowCurrent[0]).toHaveTextContent("list 2");
+ });
+
+ it("exposes reset()/getCustomListIds() as the imperative API LaneEditor drives via a ref", () => {
+ const onUpdate = jest.fn();
+ const ref = React.createRef();
+
+ const { rerender } = render(
+
+ );
+
+ expect(ref.current.getCustomListIds()).toEqual([2]);
+
+ // reset(ids) notifies the parent with the supplied original ids.
+ ref.current.reset([1]);
+ expect(onUpdate).toHaveBeenCalledTimes(1);
+ expect(onUpdate).toHaveBeenCalledWith([1]);
+
+ rerender(
+
+ );
+
+ expect(ref.current.getCustomListIds()).toEqual([1]);
+ });
+});
+
+// The drag handlers (onDragStart/onDragEnd), exercised by calling the captured
+// react-beautiful-dnd callbacks directly.
+describe("LaneCustomListsEditor - drag handlers", () => {
+ // Factory rather than a shared const: the component's add()/remove() mutate
+ // customListIds in place, so each test needs its own fresh array.
+ const makeProps = () => ({
+ allCustomLists: allCustomListsData,
+ filteredCustomLists: allCustomListsData,
+ customListIds: [2],
+ onUpdate: undefined as any,
+ });
+
+ it("adds a list when dragged from available to current", () => {
+ const onUpdate = jest.fn();
+ render();
+
+ act(() => {
+ dndCallbacks.onDragStart({ source: { droppableId: "available-lists" } });
+ dndCallbacks.onDragEnd({
+ draggableId: "1",
+ source: { droppableId: "available-lists" },
+ destination: { droppableId: "current-lists" },
+ });
+ });
+
+ // list 2 was already selected; dragging list 1 in adds it.
+ expect(onUpdate).toHaveBeenCalledWith([2, 1]);
+ expect(document.body.classList.contains("dragging")).toBe(false);
+ });
+
+ it("removes a list when dragged from current to available", () => {
+ const onUpdate = jest.fn();
+ render();
+
+ act(() => {
+ dndCallbacks.onDragEnd({
+ draggableId: 2,
+ source: { droppableId: "current-lists" },
+ destination: { droppableId: "available-lists" },
+ });
+ });
+
+ // list 2 was the only selection; dragging it out clears the list.
+ expect(onUpdate).toHaveBeenCalledWith([]);
+ });
+
+ it("does nothing when dropped outside a list (no destination)", () => {
+ const onUpdate = jest.fn();
+ render();
+
+ act(() => {
+ dndCallbacks.onDragEnd({
+ draggableId: "1",
+ source: { droppableId: "available-lists" },
+ destination: null,
+ });
+ });
+
+ expect(onUpdate).not.toHaveBeenCalled();
+ });
+
+ it("disables dropping within the available lists while dragging from available", () => {
+ render();
+
+ act(() => {
+ dndCallbacks.onDragStart({ source: { droppableId: "available-lists" } });
+ });
+
+ expect(screen.getByTestId("droppable-available-lists")).toHaveAttribute(
+ "data-drop-disabled",
+ "true"
+ );
+ });
+
+ it("disables dropping within the current lists while dragging from current", () => {
+ render();
+
+ act(() => {
+ dndCallbacks.onDragStart({ source: { droppableId: "current-lists" } });
+ });
+
+ expect(screen.getByTestId("droppable-current-lists")).toHaveAttribute(
+ "data-drop-disabled",
+ "true"
+ );
+ });
+
+ it("shows a removal message over the available lists while dragging from current, and clears it on drop", () => {
+ render();
+ const available = () => screen.getByTestId("droppable-available-lists");
+ const removalMessage = () =>
+ screen.queryByText("Drag lists here to remove them from the lane.");
+
+ // Before any drag: the available lists reject drops and show no remove zone.
+ expect(available()).toHaveAttribute("data-drop-disabled", "true");
+ expect(removalMessage()).not.toBeInTheDocument();
+
+ act(() => {
+ dndCallbacks.onDragStart({ source: { droppableId: "current-lists" } });
+ });
+
+ // Dragging from the current lists turns the available column into a remove zone.
+ expect(available()).toHaveAttribute("data-drop-disabled", "false");
+ expect(removalMessage()).toBeInTheDocument();
+
+ act(() => {
+ dndCallbacks.onDragEnd({ source: { droppableId: "current-lists" } });
+ });
+
+ // Dropping (even outside a list) resets the remove zone.
+ expect(available()).toHaveAttribute("data-drop-disabled", "true");
+ expect(removalMessage()).not.toBeInTheDocument();
+ });
+});
diff --git a/tests/jest/components/PairedMenus.test.tsx b/tests/jest/components/PairedMenus.test.tsx
new file mode 100644
index 0000000000..a7296e31b1
--- /dev/null
+++ b/tests/jest/components/PairedMenus.test.tsx
@@ -0,0 +1,220 @@
+import * as React from "react";
+import { render, screen, within, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import PairedMenus from "../../../src/components/PairedMenus";
+import { SettingData, LibraryData } from "../../../src/interfaces";
+
+describe("PairedMenus", () => {
+ const inputListSetting: SettingData = {
+ key: "input",
+ label: "Input List",
+ default: ["a", "b", "c"],
+ options: [
+ { key: "a", label: "A" },
+ { key: "b", label: "B" },
+ { key: "c", label: "C" },
+ { key: "d", label: "D" },
+ { key: "e", label: "E" },
+ ],
+ paired: "dropdown",
+ };
+ const dropdownSetting: SettingData = {
+ key: "dropdown",
+ label: "Dropdown",
+ default: "b",
+ options: [
+ { key: "a", label: "A" },
+ { key: "b", label: "B" },
+ { key: "c", label: "C" },
+ { key: "d", label: "D" },
+ { key: "e", label: "E" },
+ ],
+ type: "select",
+ };
+
+ const renderPairedMenus = (props = {}) =>
+ render(
+
+ );
+
+ // The current InputList items are rendered as read-only text inputs inside the
+ // `.input-list-ul` list; return their displayed values.
+ const listItemValues = (container: HTMLElement): string[] =>
+ within(container.querySelector(".input-list-ul")!)
+ .getAllByRole("textbox")
+ .map((input) => (input as HTMLInputElement).value);
+
+ const dropdown = (container: HTMLElement): HTMLSelectElement =>
+ container.querySelector('select[name="dropdown"]')!;
+
+ it("renders a fieldset with an InputList and a dropdown", () => {
+ const { container } = renderPairedMenus();
+ expect(
+ container.querySelector("fieldset.paired-menus")
+ ).toBeInTheDocument();
+ expect(container.querySelector(".input-list")).toBeInTheDocument();
+ expect(dropdown(container)).toBeInTheDocument();
+ });
+
+ it("renders the InputList items as read-only fields", () => {
+ const { container } = renderPairedMenus();
+ const listItems = within(
+ container.querySelector(".input-list-ul")!
+ ).getAllByRole("textbox");
+ expect(listItems).toHaveLength(3);
+ listItems.forEach((input) =>
+ expect((input as HTMLInputElement).readOnly).toBe(true)
+ );
+ expect(listItemValues(container)).toEqual(["A", "B", "C"]);
+ });
+
+ it("renders the dropdown with its default value and available options", () => {
+ const { container } = renderPairedMenus();
+ const select = dropdown(container);
+ expect(select).toHaveValue("b");
+ const options = within(select).getAllByRole("option");
+ expect(options.map((o) => o.textContent)).toEqual(["A", "B", "C"]);
+ expect(options.map((o) => (o as HTMLOptionElement).value)).toEqual([
+ "a",
+ "b",
+ "c",
+ ]);
+ });
+
+ it("optionally disables the InputList's button", () => {
+ const { rerender } = renderPairedMenus();
+ expect(screen.getByRole("button", { name: "Add" })).toBeEnabled();
+
+ rerender(
+
+ );
+ expect(screen.getByRole("button", { name: "Add" })).toBeDisabled();
+ });
+
+ it("reflects the default InputList values and dropdown value", () => {
+ const { container } = renderPairedMenus();
+ expect(listItemValues(container)).toEqual(["A", "B", "C"]);
+ expect(dropdown(container)).toHaveValue("b");
+ });
+
+ it("takes an optional item prop", () => {
+ const item: LibraryData = {
+ name: "Library",
+ short_name: "lib",
+ uuid: "123",
+ settings: {
+ input: ["c", "d", "e"],
+ dropdown: "d",
+ },
+ };
+ const { container } = renderPairedMenus({ item });
+
+ const listItems = within(
+ container.querySelector(".input-list-ul")!
+ ).getAllByRole("textbox");
+ expect(listItems.map((i) => (i as HTMLInputElement).value)).toEqual([
+ "C",
+ "D",
+ "E",
+ ]);
+ expect(listItems.map((i) => (i as HTMLInputElement).name)).toEqual([
+ "input_c",
+ "input_d",
+ "input_e",
+ ]);
+
+ const select = dropdown(container);
+ expect(select).toHaveValue("d");
+ expect(
+ within(select)
+ .getAllByRole("option")
+ .map((o) => o.textContent)
+ ).toEqual(["C", "D", "E"]);
+ });
+
+ it("updates the InputList when an item is removed", async () => {
+ const user = userEvent.setup();
+ const { container } = renderPairedMenus();
+ expect(listItemValues(container)).toEqual(["A", "B", "C"]);
+
+ // Remove the first list item ("A").
+ await user.click(screen.getAllByRole("button", { name: /Delete/ })[0]);
+
+ await waitFor(() =>
+ expect(
+ within(
+ container.querySelector(".input-list-ul")!
+ ).getAllByRole("textbox")
+ ).toHaveLength(2)
+ );
+ expect(listItemValues(container)).toEqual(["B", "C"]);
+ });
+
+ it("recalculates the dropdown options based on the InputList values", async () => {
+ const user = userEvent.setup();
+ const { container } = renderPairedMenus();
+ expect(
+ within(dropdown(container))
+ .getAllByRole("option")
+ .map((o) => o.textContent)
+ ).toEqual(["A", "B", "C"]);
+
+ // Removing "A" leaves the InputList holding [b, c], so the dropdown may only
+ // offer B and C.
+ await user.click(screen.getAllByRole("button", { name: /Delete/ })[0]);
+
+ await waitFor(() =>
+ expect(
+ within(dropdown(container))
+ .getAllByRole("option")
+ .map((o) => o.textContent)
+ ).toEqual(["B", "C"])
+ );
+ });
+
+ it("updates the dropdown selection", async () => {
+ const user = userEvent.setup();
+ const { container } = renderPairedMenus();
+ const select = dropdown(container);
+ expect(select).toHaveValue("b");
+
+ await user.selectOptions(select, "c");
+ expect(select).toHaveValue("c");
+ });
+
+ it("displays a message when the dropdown has no available values", async () => {
+ const user = userEvent.setup();
+ const { container } = renderPairedMenus();
+
+ // Remove every item from the InputList.
+ for (let remaining = 3; remaining > 0; remaining--) {
+ const removeButtons = screen.getAllByRole("button", { name: /Delete/ });
+ expect(removeButtons).toHaveLength(remaining);
+ await user.click(removeButtons[0]);
+ await waitFor(() =>
+ expect(
+ screen.queryAllByRole("button", { name: /Delete/ })
+ ).toHaveLength(remaining - 1)
+ );
+ }
+
+ const message =
+ "In order to set this value, you must add at least one option from the menu above.";
+ expect(screen.getByText(message)).toBeInTheDocument();
+ expect(container.querySelector(".bg-warning")).toHaveTextContent(message);
+
+ // The dropdown select is replaced by the warning; only the add menu remains.
+ expect(dropdown(container)).not.toBeInTheDocument();
+ expect(container.querySelectorAll("select")).toHaveLength(1);
+ });
+});