diff --git a/src/components/__tests__/BookCoverEditor-test.tsx b/src/components/__tests__/BookCoverEditor-test.tsx deleted file mode 100644 index 6c8b581474..0000000000 --- a/src/components/__tests__/BookCoverEditor-test.tsx +++ /dev/null @@ -1,330 +0,0 @@ -import { expect } from "chai"; -import { stub } from "sinon"; - -import * as React from "react"; -import { mount } from "enzyme"; - -import { BookCoverEditor } from "../BookCoverEditor"; -import EditableInput from "../EditableInput"; -import ErrorMessage from "../ErrorMessage"; -import { BookData, RightsStatusData } from "../../interfaces"; - -describe("BookCoverEditor", () => { - const rightsStatuses: RightsStatusData = { - "http://creativecommons.org/licenses/by/4.0/": { - allows_derivatives: true, - name: "Creative Commons Attribution (CC BY)", - open_access: true, - }, - "http://librarysimplified.org/terms/rights-status/in-copyright": { - allows_derivatives: false, - name: "In Copyright", - open_access: false, - }, - "https://creativecommons.org/licenses/by-nd/4.0": { - allows_derivatives: false, - name: "Creative Commons Attribution-NoDerivs (CC BY-ND)", - open_access: true, - }, - }; - - const bookData: BookData = { - id: "id", - title: "title", - coverUrl: "/cover", - changeCoverLink: { - href: "/change_cover", - rel: "http://librarysimplified.org/terms/rel/change_cover", - }, - }; - - let wrapper; - const editableInputByName = (name) => { - const inputs = wrapper.find(EditableInput); - return inputs.filterWhere((input) => input.props().name === name); - }; - - describe("rendering", () => { - beforeEach(() => { - wrapper = mount( - - ); - }); - - it("shows book title", () => { - const title = wrapper.find("h2"); - expect(title.text()).to.equal(bookData.title); - }); - - it("shows updating message", () => { - const updatingContainer = wrapper.find(".updating-loader-container"); - let updating = wrapper.find(".updating-loader"); - expect(updatingContainer.length).to.equal(2); - expect(updating.length).to.equal(0); - - wrapper.setProps({ isFetching: true }); - updating = wrapper.find(".updating-loader"); - - // The second loader component renders based on a different prop, - // so only one loader should be rendered. - expect(updating.length).to.equal(1); - expect(updating.text()).to.contain("Updating"); - }); - - it("shows current cover", () => { - const cover = wrapper.find(".current-cover"); - expect(cover.length).to.equal(1); - expect(cover.props().src).to.equal(bookData.coverUrl); - expect(cover.props().alt).to.equal("Current book cover"); - }); - - it("shows cover URL and cover file inputs", () => { - const coverUrl = editableInputByName("cover_url"); - expect(coverUrl.props().label).to.equal("URL for cover image"); - - const coverFile = editableInputByName("cover_file"); - expect(coverFile.props().label).to.equal("Or upload cover image"); - expect(coverFile.props().type).to.equal("file"); - expect(coverFile.props().accept).to.equal("image/*"); - }); - - it("shows title position input", () => { - const input = editableInputByName("title_position"); - expect(input.props().elementType).to.equal("select"); - expect(input.props().label).to.equal("Title and Author Position"); - const options = input.find("option"); - expect(options.length).to.equal(4); - expect(options.at(0).props().value).to.equal("none"); - expect(options.at(1).props().value).to.equal("top"); - expect(options.at(2).props().value).to.equal("center"); - expect(options.at(3).props().value).to.equal("bottom"); - }); - - it("shows preview updating message", () => { - let updatingContainer = wrapper.find(".updating-loader-container"); - let updating = wrapper.find(".updating-loader"); - expect(updatingContainer.length).to.equal(2); - expect(updating.length).to.equal(0); - - wrapper.setProps({ isFetchingPreview: true }); - updatingContainer = wrapper.find(".updating-loader-container"); - updating = wrapper.find(".updating-loader"); - - // This specific loader renders based on the `isFetchingPreview` prop. - expect(updating.length).to.equal(1); - expect(updating.text()).to.contain("Updating Preview"); - }); - - it("shows preview", () => { - let preview = wrapper.find(".preview-cover"); - expect(preview.length).to.equal(0); - - wrapper.setProps({ preview: "image data" }); - preview = wrapper.find(".preview-cover"); - expect(preview.length).to.equal(1); - expect(preview.props().src).to.equal("image data"); - expect(preview.props().alt).to.equal("Preview of new cover"); - }); - - it("shows rights inputs", () => { - const rightsStatusInput = editableInputByName("rights_status"); - expect(rightsStatusInput.length).to.equal(1); - expect(rightsStatusInput.props().elementType).to.equal("select"); - expect(rightsStatusInput.props().label).to.equal("License"); - - const children = rightsStatusInput.find("option"); - expect(children.length).to.equal(3); - expect(children.at(0).props().value).to.equal( - "http://creativecommons.org/licenses/by/4.0/" - ); - expect(children.at(0).text()).to.equal( - "Creative Commons Attribution (CC BY)" - ); - expect(children.at(1).props().value).to.equal( - "http://librarysimplified.org/terms/rights-status/in-copyright" - ); - expect(children.at(1).text()).to.equal("In Copyright"); - expect(children.at(2).props().value).to.equal( - "http://librarysimplified.org/terms/rights-status/unknown" - ); - expect(children.at(2).text()).to.equal("Other"); - - const explanationInput = editableInputByName("rights_explanation"); - expect(explanationInput.length).to.equal(1); - expect(explanationInput.props().label).to.equal("Explanation of rights"); - }); - - it("shows fetch error", () => { - let error = wrapper.find(ErrorMessage); - expect(error.length).to.equal(0); - - const errorData = { status: 500, url: "url", response: "error" }; - wrapper.setProps({ fetchError: errorData }); - error = wrapper.find(ErrorMessage); - expect(error.props().error).to.equal(errorData); - }); - - it("shows preview fetch error", () => { - let error = wrapper.find(ErrorMessage); - expect(error.length).to.equal(0); - - const errorData = { status: 500, url: "url", response: "error" }; - wrapper.setProps({ previewFetchError: errorData }); - error = wrapper.find(ErrorMessage); - expect(error.props().error).to.equal(errorData); - }); - - it("shows save button", () => { - const buttons = wrapper.find("button"); - // Counting the two buttons that are coming from the Panel component: - expect(buttons.length).to.equal(4); - let save = buttons.at(3); - expect(save.props().disabled).to.be.ok; - - wrapper.setProps({ preview: "image data" }); - save = wrapper.find("button").at(3); - expect(save.props().disabled).not.to.be.ok; - }); - }); - - describe("behavior", () => { - let fetchBook; - let fetchPreview; - let clearPreview; - let editCover; - let fetchRightsStatuses; - let refreshCatalog; - - beforeEach(() => { - fetchBook = stub(); - fetchPreview = stub(); - clearPreview = stub(); - editCover = stub().returns(new Promise((resolve) => resolve())); - fetchRightsStatuses = stub(); - refreshCatalog = stub(); - wrapper = mount( - - ); - }); - - it("clears preview on mount", () => { - expect(clearPreview.callCount).to.equal(1); - }); - - it("fetches rights status on mount", () => { - expect(fetchRightsStatuses.callCount).to.equal(1); - }); - - it("previews cover when relevant inputs change", () => { - expect(fetchPreview.callCount).to.equal(0); - expect(clearPreview.callCount).to.equal(1); - - const previewButton = wrapper.find("button").at(1); - const coverUrl = editableInputByName("cover_url"); - coverUrl.setState({ value: "http://example.com" }); - previewButton.simulate("click"); - - expect(fetchPreview.callCount).to.equal(1); - expect(fetchPreview.args[0][0]).to.equal( - "/admin/book/preview_book_cover" - ); - let formData = fetchPreview.args[0][1]; - expect(formData.get("cover_url")).to.equal("http://example.com"); - expect(formData.get("title_position")).to.equal("none"); - - const titlePosition = editableInputByName("title_position"); - titlePosition.setState({ value: "center" }); - previewButton.simulate("click"); - - expect(fetchPreview.callCount).to.equal(2); - expect(fetchPreview.args[1][0]).to.equal( - "/admin/book/preview_book_cover" - ); - formData = fetchPreview.args[1][1]; - expect(formData.get("cover_url")).to.equal("http://example.com"); - expect(formData.get("cover_file").name).to.equal(""); - expect(formData.get("title_position")).to.equal("center"); - coverUrl.setState({ value: "" }); - previewButton.simulate("click"); - - expect(fetchPreview.callCount).to.equal(2); - expect(clearPreview.callCount).to.equal(2); - }); - - it("saves", async () => { - expect(editCover.callCount).to.equal(0); - expect(fetchBook.callCount).to.equal(0); - expect(refreshCatalog.callCount).to.equal(0); - wrapper.setProps({ preview: "image data" }); - - const coverUrl = editableInputByName("cover_url"); - coverUrl.at(0).setState({ value: "http://example.com" }); - - const titlePosition = editableInputByName("title_position"); - titlePosition.at(0).setState({ value: "center" }); - - const rightsStatus = editableInputByName("rights_status"); - rightsStatus - .at(0) - .setState({ value: "http://creativecommons.org/licenses/by/4.0/" }); - - const rightsExplanation = editableInputByName("rights_explanation"); - rightsExplanation.at(0).setState({ value: "explanation" }); - - const saveButton = wrapper.find("button").at(3); - saveButton.simulate("click"); - - expect(editCover.callCount).to.equal(1); - expect(editCover.args[0][0]).to.equal("/change_cover"); - const formData = editCover.args[0][1]; - expect(formData.get("cover_url")).to.equal("http://example.com"); - expect(formData.get("title_position")).to.equal("center"); - expect(formData.get("rights_status")).to.equal( - "http://creativecommons.org/licenses/by/4.0/" - ); - expect(formData.get("rights_explanation")).to.equal("explanation"); - - const pause = (): Promise => { - return new Promise((resolve) => setTimeout(resolve, 0)); - }; - await pause(); - - expect(fetchBook.callCount).to.equal(1); - expect(refreshCatalog.callCount).to.equal(1); - }); - }); -}); diff --git a/src/components/__tests__/CatalogPage-test.tsx b/src/components/__tests__/CatalogPage-test.tsx deleted file mode 100644 index 14d9c58b3f..0000000000 --- a/src/components/__tests__/CatalogPage-test.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { expect } from "chai"; - -import * as React from "react"; -import { shallow } from "enzyme"; - -import CatalogPage from "../CatalogPage"; -import OPDSCatalog from "@thepalaceproject/web-opds-client/lib/components/OPDSCatalog"; -import Header from "../Header"; -import Footer from "../Footer"; -import WelcomePage from "../WelcomePage"; -import BookDetailsContainer from "../BookDetailsContainer"; -import title from "../../utils/title"; - -describe("CatalogPage", () => { - let wrapper; - let params; - const host = "http://example.com"; - - beforeEach(() => { - global.jsdom.reconfigure({ url: host + "/test" }); - params = { - collectionUrl: "library/collectionurl", - bookUrl: "library/bookurl", - tab: "tab", - }; - wrapper = shallow(); - }); - - it("renders OPDSCatalog", () => { - const catalog = wrapper.find(OPDSCatalog); - expect(catalog.prop("collectionUrl")).to.equal( - host + "/library/collectionurl" - ); - expect(catalog.prop("bookUrl")).to.equal(host + "/library/works/bookurl"); - expect(catalog.prop("BookDetailsContainer").name).to.equal( - BookDetailsContainer.name - ); - expect(catalog.prop("Header").name).to.equal(Header.name); - expect(catalog.prop("computeBreadcrumbs")).to.be.ok; - const pageTitleTemplate = catalog.prop("pageTitleTemplate"); - expect(pageTitleTemplate("Collection", "Book")).to.equal(title("Book")); - expect(pageTitleTemplate("Collection", null)).to.equal(title("Collection")); - }); - - it("handles the case in which the URL already contains a query string", () => { - const queryUrl = "library/collectionurl?samplequery=test"; - expect(wrapper.instance().expandCollectionUrl(queryUrl)).to.equal( - host + "/library/collectionurl?samplequery=test" - ); - }); - - it("renders welcome page when there's no library", () => { - const newParams = Object.assign({}, params, { - collectionUrl: null, - bookUrl: null, - tab: null, - }); - wrapper.setProps({ params: newParams }); - const catalog = wrapper.find(OPDSCatalog); - expect(catalog.length).to.equal(0); - const welcomePage = wrapper.find(WelcomePage); - expect(welcomePage.length).to.equal(1); - }); - - it("includes tab in child context", () => { - const context = wrapper.instance().getChildContext(); - expect(context.tab).to.equal("tab"); - }); - - it("includes library in child context", () => { - let context = wrapper.instance().getChildContext(); - expect(context.library()).to.equal("library"); - - let newParams = Object.assign({}, params, { - collectionUrl: null, - bookUrl: "library/bookurl", - }); - wrapper.setProps({ params: newParams }); - context = wrapper.instance().getChildContext(); - expect(context.library()).to.equal("library"); - - newParams = Object.assign({}, params, { - collectionUrl: null, - bookUrl: null, - }); - wrapper.setProps({ params: newParams }); - context = wrapper.instance().getChildContext(); - expect(context.library()).to.equal(null); - }); - - it("shows Footer", () => { - const footer = wrapper.find(Footer); - expect(footer.length).to.equal(1); - }); -}); diff --git a/src/components/__tests__/ClassificationsForm-test.tsx b/src/components/__tests__/ClassificationsForm-test.tsx deleted file mode 100644 index fab8766f4a..0000000000 --- a/src/components/__tests__/ClassificationsForm-test.tsx +++ /dev/null @@ -1,438 +0,0 @@ -import { expect } from "chai"; -import { stub } from "sinon"; - -import * as React from "react"; -import { mount } from "enzyme"; - -import ClassificationsForm from "../ClassificationsForm"; -import EditableInput from "../EditableInput"; -import WithRemoveButton from "../WithRemoveButton"; -import GenreForm from "../GenreForm"; -import genreData from "./genreData"; - -describe("ClassificationsForm", () => { - let wrapper; - let instance; - let bookData; - let editClassifications; - - const editableInputByName = (name) => { - const inputs = wrapper.find(EditableInput); - return inputs.filterWhere((input) => input.props().name === name); - }; - - describe("rendering without classification values", () => { - let confirmStub; - beforeEach(() => { - bookData = { - title: "title", - audience: undefined, - targetAgeRange: ["12", "16"], - fiction: undefined, - categories: ["Space Opera"], - }; - editClassifications = stub(); - confirmStub = stub(window, "confirm").returns(true); - wrapper = mount( - - ); - instance = wrapper.instance(); - }); - - afterEach(() => { - confirmStub.restore(); - }); - - it("should have no values displayed for the audience or fiction classifications", () => { - const select = editableInputByName("audience"); - expect(select.props().label).to.equal("Audience"); - expect(select.props().value).to.equal("None"); - - const options = select.find("select").children(); - expect(options.length).to.equal(7); - - // This only gets rendered without an initial fiction classification: - const noFictionSelectedRadio = wrapper - .find(EditableInput) - .filterWhere((input) => input.props().value === "none"); - const fictionRadio = wrapper - .find(EditableInput) - .filterWhere((input) => input.props().value === "fiction"); - const nonfictionRadio = wrapper - .find(EditableInput) - .filterWhere((input) => input.props().value === "nonfiction"); - - expect(noFictionSelectedRadio.props().type).to.equal("radio"); - expect(noFictionSelectedRadio.props().label).to.equal("None"); - expect(noFictionSelectedRadio.props().checked).to.equal(true); - expect(noFictionSelectedRadio.props().name).to.equal("fiction"); - - expect(fictionRadio.props().type).to.equal("radio"); - expect(fictionRadio.props().label).to.equal("Fiction"); - expect(fictionRadio.props().checked).to.equal(false); - expect(fictionRadio.props().name).to.equal("fiction"); - - expect(nonfictionRadio.props().type).to.equal("radio"); - expect(nonfictionRadio.props().label).to.equal("Nonfiction"); - expect(nonfictionRadio.props().checked).to.equal(false); - expect(nonfictionRadio.props().name).to.equal("fiction"); - }); - - it("should not allow you to submit if you didn't select an audience or a fiction classification", () => { - const button = wrapper - .find("button") - .findWhere((button) => button.text() === "Save") - .at(0); - button.simulate("click"); - - expect(wrapper.state().audience).to.equal("None"); - expect(wrapper.state().fiction).to.equal(undefined); - expect(editClassifications.callCount).to.equal(0); - }); - - it("should render error messages without an audience or a fiction classification", () => { - const button = wrapper - .find("button") - .findWhere((button) => button.text() === "Save") - .at(0); - - button.simulate("click"); - const alert = wrapper.find(".alert-danger"); - - expect(editClassifications.callCount).to.equal(0); - expect(alert.length).to.equal(1); - expect(alert.text()).to.equal( - "No Audience classification selected.No Fiction classification selected." - ); - }); - - it("moves focus to the error message so it is announced", () => { - jest.useFakeTimers(); - // `mount` renders detached from the document, so `document.activeElement` - // never updates; watch the `focus()` call itself instead. - const focus = stub(HTMLElement.prototype, "focus"); - - try { - const button = wrapper - .find("button") - .findWhere((button) => button.text() === "Save") - .at(0); - button.simulate("click"); - wrapper.update(); - - expect(focus.callCount).to.equal(0); - - jest.advanceTimersByTime(500); - - expect(focus.callCount).to.equal(1); - expect(focus.thisValues[0]).to.equal( - wrapper.find(".alert-danger").at(0).getDOMNode() - ); - } finally { - focus.restore(); - jest.useRealTimers(); - } - }); - - it("should not allow you to submit if you didn't select an audience", () => { - const button = wrapper - .find("button") - .findWhere((button) => button.text() === "Save") - .at(0); - let alert; - wrapper.setState({ fiction: true }); - - button.simulate("click"); - alert = wrapper.find(".alert-danger"); - expect(editClassifications.callCount).to.equal(0); - expect(alert.length).to.equal(1); - - const select = wrapper.find("select[name='audience']") as any; - const selectElement = select.getDOMNode(); - selectElement.value = "Adult"; - select.simulate("change"); - button.simulate("click"); - - alert = wrapper.find(".alert-danger"); - expect(editClassifications.callCount).to.equal(1); - // The alert message should go away. - expect(alert.length).to.equal(0); - }); - - it("should not allow you to submit if you didn't select a fiction classification", () => { - const button = wrapper - .find("button") - .findWhere((button) => button.text() === "Save") - .at(0); - wrapper.setState({ audience: "Adult" }); - - button.simulate("click"); - expect(editClassifications.callCount).to.equal(0); - - const nonfictionInput = wrapper.find("input[value='nonfiction']"); - const nonfictionElement = nonfictionInput.getDOMNode(); - expect((nonfictionElement as any).checked).to.equal(false); - - (nonfictionElement as any).checked = true; - nonfictionInput.simulate("change"); - button.simulate("click"); - - expect(editClassifications.callCount).to.equal(1); - }); - }); - - describe("rendering", () => { - beforeEach(() => { - bookData = { - title: "title", - audience: "Young Adult", - targetAgeRange: ["12", "16"], - fiction: true, - categories: ["Space Opera"], - }; - editClassifications = stub(); - wrapper = mount( - - ); - instance = wrapper.instance(); - }); - - it("shows editable select with audience options", () => { - const select = editableInputByName("audience"); - expect(select.props().label).to.equal("Audience"); - expect(select.props().value).to.equal("Young Adult"); - - const options = select.find("select").children(); - // The "None" select Audience value should not be rendered. - expect(options.length).to.equal(6); - }); - - it("shows editable inputs with min and max target age", () => { - let input = editableInputByName("target_age_min"); - expect(input.props().label).not.to.be.ok; - expect(input.props().value).to.equal("12"); - - input = editableInputByName("target_age_max"); - expect(input.props().label).not.to.be.ok; - expect(input.props().value).to.equal("16"); - }); - - it("shows editable radio buttons with fiction status", () => { - const fictionRadio = wrapper - .find(EditableInput) - .filterWhere((input) => input.props().value === "fiction"); - const nonfictionRadio = wrapper - .find(EditableInput) - .filterWhere((input) => input.props().value === "nonfiction"); - - expect(fictionRadio.props().type).to.equal("radio"); - expect(fictionRadio.props().label).to.equal("Fiction"); - expect(fictionRadio.props().checked).to.equal(true); - expect(fictionRadio.props().name).to.equal("fiction"); - - expect(nonfictionRadio.props().type).to.equal("radio"); - expect(nonfictionRadio.props().label).to.equal("Nonfiction"); - expect(nonfictionRadio.props().checked).to.equal(false); - expect(nonfictionRadio.props().name).to.equal("fiction"); - }); - - it("shows the book's full genres with remove buttons", () => { - const genres = wrapper.find(WithRemoveButton); - expect(genres.length).to.equal(1); - expect(genres.props().children).to.contain( - "Science Fiction > Space Opera" - ); - }); - - it("shows the book's full genres with remove buttons even if inconsistent with fiction status", () => { - const inconsistentBookData = Object.assign({}, bookData, { - fiction: false, - }); - wrapper.setProps({ book: inconsistentBookData }); - - const genres = wrapper.find(WithRemoveButton); - expect(genres.length).to.equal(1); - expect(genres.props().children).to.contain( - "Science Fiction > Space Opera" - ); - }); - - it("shows genre form", () => { - const form = wrapper.find(GenreForm); - expect(form.length).to.equal(1); - expect(form.props().disabled).not.to.be.ok; - expect(form.props().genreOptions).to.deep.equal(instance.genreOptions()); - expect(form.props().bookGenres).to.deep.equal( - instance.bookGenres(bookData) - ); - }); - - it("shows submit button", () => { - const button = wrapper - .find("button") - .filterWhere((button) => button.text() === "Save"); - expect(button.length).to.equal(1); - }); - }); - - describe("behavior", () => { - let confirmStub; - - beforeEach(() => { - confirmStub = stub(window, "confirm").returns(true); - bookData = { - title: "title", - audience: "Young Adult", - targetAgeRange: ["12", "16"], - fiction: true, - categories: ["Space Opera"], - }; - editClassifications = stub(); - wrapper = mount( - - ); - instance = wrapper.instance(); - }); - - afterEach(() => { - confirmStub.restore(); - }); - - it("shows and hides target age inputs when audience changes", () => { - let minAgeInput = editableInputByName("target_age_min"); - let maxAgeInput = editableInputByName("target_age_max"); - expect(minAgeInput.length).to.equal(1); - expect(maxAgeInput.length).to.equal(1); - - const select = wrapper.find("select[name='audience']") as any; - const selectElement = select.getDOMNode(); - selectElement.value = "Adult"; - select.simulate("change"); - minAgeInput = editableInputByName("target_age_min"); - maxAgeInput = editableInputByName("target_age_max"); - expect(minAgeInput.length).to.equal(0); - expect(maxAgeInput.length).to.equal(0); - - selectElement.value = "Children"; - select.simulate("change"); - minAgeInput = editableInputByName("target_age_min"); - maxAgeInput = editableInputByName("target_age_max"); - expect(minAgeInput.length).to.equal(1); - expect(maxAgeInput.length).to.equal(1); - }); - - it("changes both fiction status radio buttons", () => { - const fictionInput = wrapper.find("input[value='fiction']"); - const nonfictionInput = wrapper.find("input[value='nonfiction']"); - expect(fictionInput.length).to.equal(1); - expect(nonfictionInput.length).to.equal(1); - - const fictionElement = fictionInput.getDOMNode(); - const nonfictionElement = nonfictionInput.getDOMNode(); - expect((fictionElement as any).checked).to.equal(true); - expect((nonfictionElement as any).checked).to.equal(false); - - (nonfictionElement as any).checked = true; - nonfictionInput.simulate("change"); - - // change to nonfiction should prompt user and clear fiction genre - expect(confirmStub.callCount).to.be.above(0); - expect(wrapper.state("genres")).to.deep.equal([]); - - expect((fictionElement as any).checked).to.equal(false); - expect((nonfictionElement as any).checked).to.equal(true); - - (fictionElement as any).checked = true; - fictionInput.simulate("change"); - - expect((fictionElement as any).checked).to.equal(true); - expect((nonfictionElement as any).checked).to.equal(false); - }); - - it("adds genre to list of selected genres after validating against audience", () => { - // can't add Erotica to book with Young Adult audience - instance.addGenre("Erotica"); - let newGenres = wrapper.find(WithRemoveButton).map((name) => name.text()); - expect(newGenres.length).to.equal(1); - expect(newGenres[0]).to.contain( - instance.fullGenre(bookData.categories[0]) + "Delete" - ); - expect(newGenres[0]).not.to.contain("Erotica"); - - instance.validateAudience = stub().returns(true); - expect(instance.validateAudience.callCount).to.equal(0); - instance.addGenre("Folklore"); - wrapper.update(); - - expect(instance.validateAudience.callCount).to.equal(1); - newGenres = wrapper.find(WithRemoveButton).map((name) => name.text()); - expect(newGenres[0]).to.contain( - instance.fullGenre("Folklore") + "Delete" - ); - }); - - it("removes genre when remove button is clicked", () => { - const button = wrapper.find(WithRemoveButton); - const onRemove = button.props().onRemove; - onRemove(); - wrapper.update(); - - const newGenres = wrapper.find(WithRemoveButton); - expect(newGenres.length).to.equal(0); - }); - - it("submits data when submit button is clicked", () => { - const button = wrapper - .find("button") - .findWhere((button) => button.text() === "Save") - .at(0); - button.simulate("click"); - - const formData = new (window as any).FormData(); - formData.append("csrf_token", "token"); - formData.append("audience", "Young Adult"); - formData.append("target_age_min", "12"); - formData.append("target_age_max", "16"); - formData.append("fiction", "fiction"); - formData.append("genres", "Space Opera"); - - expect(editClassifications.callCount).to.equal(1); - expect(editClassifications.args[0][0]).to.deep.equal(formData); - }); - - it("updates state upon receiving new state-related props", () => { - const newBookData = Object.assign({}, bookData, { - audience: "Adult", - fiction: false, - categories: ["Cooking"], - }); - wrapper.setProps({ book: newBookData }); - - expect(wrapper.state("audience")).to.equal("Adult"); - expect(wrapper.state("fiction")).to.equal(false); - expect(wrapper.state("genres")).to.deep.equal(["Cooking"]); - }); - - it("doesn't update state upon receiving new state-unrelated props", () => { - // state updated with new form inputs - wrapper.setState({ fiction: false, genres: ["Cooking"] }); - // form submitted, disabling form - wrapper.setProps({ disabled: true }); - // state should not change back to earlier book props - expect(wrapper.state("fiction")).to.equal(false); - expect(wrapper.state("genres")).to.deep.equal(["Cooking"]); - }); - }); -}); diff --git a/src/components/__tests__/Contributors-test.tsx b/src/components/__tests__/Contributors-test.tsx deleted file mode 100644 index c477c81b5c..0000000000 --- a/src/components/__tests__/Contributors-test.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import { expect } from "chai"; -import * as React from "react"; -import { mount } from "enzyme"; -import { spy } from "sinon"; - -import Contributors from "../Contributors"; -import { RolesData, ContributorData } from "../../interfaces"; - -describe("Contributors", () => { - const roles: RolesData = { - aut: "Author", - ill: "Illustrator", - nrt: "Narrator", - trl: "Translator", - }; - const contributors: ContributorData[] = [ - { name: "A Translator", role: "trl" }, - { name: "A Narrator", role: "nrt" }, - ]; - let wrapper; - - const hasSelect = (element, value: string) => { - const select = element.find("select"); - expect(select.length).to.equal(1); - expect(select.prop("name")).to.equal("contributor-role"); - expect(select.prop("value")).to.equal(value); - expect(select.find("option").map((o) => o.prop("value"))).to.eql( - Object.values(roles) - ); - }; - - const hasInput = (element, value = "") => { - const input = element.find("input"); - expect(element.length).to.equal(1); - expect(input.prop("name")).to.equal("contributor-name"); - expect(input.prop("value")).to.equal(value); - }; - - beforeEach(() => { - wrapper = mount( - - ); - }); - - it("displays a label", () => { - const label = wrapper.find("label").at(0); - expect(label.text()).to.equal("Authors and Contributors"); - }); - - it("displays a list of existing contributors", () => { - const removables = wrapper.find(".with-remove-button"); - expect(removables.length).to.equal(2); - removables.forEach((el, idx) => { - expect(hasSelect(el, roles[contributors[idx].role])); - expect(hasInput(el, contributors[idx].name)); - }); - }); - - it("displays a blank field for adding a new contributor", () => { - const newContributorForm = wrapper.find(".contributor-form").last(); - expect(hasSelect(newContributorForm, "Author")); - expect(hasInput(newContributorForm)); - const button = newContributorForm.find(".btn.add-contributor"); - expect(button.length).to.equal(1); - expect(button.text()).to.equal("Add"); - }); - - it("deletes a contributor", () => { - expect(wrapper.state()["contributors"]).to.eql(contributors); - let existingContributors = wrapper.find(".with-remove-button"); - expect(existingContributors.length).to.equal(2); - expect( - hasSelect(existingContributors.first(), roles[contributors[0].role]) - ); - expect(hasInput(existingContributors.first(), contributors[0].name)); - existingContributors.first().find("button").simulate("click"); - expect(wrapper.state()["contributors"]).to.eql([contributors[1]]); - existingContributors = wrapper.find(".with-remove-button"); - expect(existingContributors.length).to.equal(1); - expect( - hasSelect(existingContributors.first(), roles[contributors[1].role]) - ); - expect(hasInput(existingContributors.first(), contributors[1].name)); - }); - - it("adds a contributor", () => { - expect(wrapper.state()["contributors"]).to.eql(contributors); - let existingContributors = wrapper.find(".with-remove-button"); - expect(existingContributors.length).to.equal(2); - const newContributorForm = wrapper.find(".contributor-form").last(); - newContributorForm.find("select").getDOMNode().value = "Illustrator"; - newContributorForm.find("select").simulate("change"); - newContributorForm.find("input").getDOMNode().value = "An Illustrator"; - newContributorForm.find("input").simulate("change"); - newContributorForm.find("button").simulate("click"); - expect(wrapper.state()["contributors"]).to.eql( - contributors.concat({ role: "Illustrator", name: "An Illustrator" }) - ); - existingContributors = wrapper.find(".with-remove-button"); - expect(existingContributors.length).to.equal(3); - expect(existingContributors.last().find("select").prop("value")).to.equal( - "Illustrator" - ); - expect(existingContributors.last().find("input").prop("value")).to.equal( - "An Illustrator" - ); - }); - - it("does not add an empty string as a contributor's name", () => { - const spyAddContributor = spy(wrapper.instance(), "addContributor"); - const newContributorForm = wrapper.find(".contributor-form").last(); - const button = newContributorForm.find("button"); - expect(wrapper.state()["disabled"]).to.be.true; - button.simulate("click"); - // Nothing happens. - expect(spyAddContributor.callCount).to.equal(0); - - newContributorForm.find("input").getDOMNode().value = "An Author"; - newContributorForm.find("input").simulate("change"); - expect(wrapper.state()["disabled"]).to.be.false; - button.simulate("click"); - // The button works now! - expect(spyAddContributor.callCount).to.equal(1); - - // Deleting the text disables the button again. - newContributorForm.find("input").getDOMNode().value = ""; - newContributorForm.find("input").simulate("change"); - expect(wrapper.state()["disabled"]).to.be.true; - expect(wrapper.state()["disabled"]).to.be.true; - button.simulate("click"); - expect(spyAddContributor.callCount).to.equal(1); - - spyAddContributor.restore(); - }); - - it("looks up the full name of a contributor's role", () => { - expect(wrapper.instance().getContributorRole(contributors[0])).to.equal( - "Translator" - ); - // If the look-up doesn't yield anything, it just uses the abbreviated version. - const adapter = { name: "An Adapter", role: "adp" }; - expect(wrapper.instance().getContributorRole(adapter)).to.equal("adp"); - }); - - it("resets the form after adding a contributor", () => { - // Add a contributor: - let newContributorForm = wrapper.find(".contributor-form").last(); - newContributorForm.find("select").getDOMNode().value = "Illustrator"; - newContributorForm.find("select").simulate("change"); - newContributorForm.find("input").getDOMNode().value = "An Illustrator"; - newContributorForm.find("input").simulate("change"); - newContributorForm.find("button").simulate("click"); - - newContributorForm = wrapper.find(".contributor-form").last(); - // The input field is blank. - expect(newContributorForm.find("input").prop("value")).to.equal(""); - // The drop-down menu has defaulted to "Author". - expect(newContributorForm.find("select").prop("value")).to.equal("Author"); - // The button is disabled. - expect(wrapper.state()["disabled"]).to.be.true; - }); -}); diff --git a/src/components/__tests__/EditorField-test.tsx b/src/components/__tests__/EditorField-test.tsx deleted file mode 100644 index 1979f9f693..0000000000 --- a/src/components/__tests__/EditorField-test.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { expect } from "chai"; -import { stub } from "sinon"; - -import * as React from "react"; -import { mount } from "enzyme"; - -import EditorField from "../EditorField"; -import { Editor } from "draft-js"; - -describe("EditorField", () => { - let wrapper; - - beforeEach(() => { - wrapper = mount( - - ); - }); - - it("renders buttons", () => { - const stubChangeStyle = stub(wrapper.instance(), "changeStyle"); - const buttons = wrapper.find("button"); - expect(buttons.length).to.equal(3); - expect(stubChangeStyle.callCount).to.equal(0); - - const bold = buttons.at(0); - expect(bold.text()).to.equal("Bold"); - expect(bold.find("b").length).to.equal(1); - bold.simulate("mouseDown"); - expect(stubChangeStyle.callCount).to.equal(1); - expect(stubChangeStyle.args[0][1]).to.equal("BOLD"); - - const italic = buttons.at(1); - expect(italic.text()).to.equal("Italic"); - expect(italic.find("i").length).to.equal(1); - italic.simulate("mouseDown"); - expect(stubChangeStyle.callCount).to.equal(2); - expect(stubChangeStyle.args[1][1]).to.equal("ITALIC"); - - const underline = buttons.at(2); - expect(underline.text()).to.equal("Underline"); - expect(underline.find("u").length).to.equal(1); - underline.simulate("mouseDown"); - expect(stubChangeStyle.callCount).to.equal(3); - expect(stubChangeStyle.args[2][1]).to.equal("UNDERLINE"); - - stubChangeStyle.restore(); - }); - - it("gets the current value", () => { - expect(wrapper.instance().getValue()).to.equal("

This is a summary.

"); - }); - - it("gets the default content if no content is an empty html string", () => { - // The empty paragraph element seems to be the default that is - // injected whenever the editor is saved with no content. We are checking - // against that in case the summary is empty or it was removed by - // the admin. - wrapper = mount(); - expect(wrapper.instance().getValue()).to.equal( - "

Update the content for this field.

" - ); - - // The empty string case. - wrapper = mount(); - expect(wrapper.instance().getValue()).to.equal( - "

Update the content for this field.

" - ); - - wrapper = mount(); - expect(wrapper.instance().getValue()).to.equal( - "

Update the content for this field.

" - ); - }); - - it("can be disabled", () => { - let buttons = wrapper.find("button"); - let editor = wrapper.find(Editor); - expect(buttons.every((b) => !b.prop("disabled"))); - expect(editor.prop("readOnly")).to.be.false; - - wrapper.setProps({ disabled: true }); - - buttons = wrapper.find("button"); - editor = wrapper.find(Editor); - expect(buttons.every((b) => b.prop("disabled"))); - expect(editor.prop("readOnly")).to.be.true; - }); -}); diff --git a/src/components/__tests__/GenreForm-test.tsx b/src/components/__tests__/GenreForm-test.tsx deleted file mode 100644 index 5dcfdd64f5..0000000000 --- a/src/components/__tests__/GenreForm-test.tsx +++ /dev/null @@ -1,128 +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 GenreForm from "../GenreForm"; -import genreData from "./genreData"; - -describe("GenreForm", () => { - let wrapper; - const genreOptions = Object.keys(genreData["Fiction"]).map( - (name) => genreData["Fiction"][name] - ); - const bookGenres = ["Adventure", "Epic Fantasy"]; - let addGenre; - - beforeEach(() => { - addGenre = stub().returns( - new Promise((resolve, _reject) => resolve()) - ); - wrapper = shallow( - - ); - }); - - describe("rendering", () => { - it("shows multiple select with top-level genres", () => { - const handleGenreSelect = (wrapper.instance() as any).handleGenreSelect; - const topLevelGenres = genreOptions.filter( - (genre) => genre.parents.length === 0 - ); - const options = wrapper.find("select[name='genre']").find("option"); - - expect(options.length).to.equal(topLevelGenres.length); - options.forEach((option, i) => { - const name = topLevelGenres[i].name; - const hasGenre = bookGenres.indexOf(name) !== -1; - const hasSubgenre = topLevelGenres[i].subgenres.length > 0; - const displayName = name + (hasSubgenre ? " >" : ""); - expect(option.props().value).to.equal(name); - expect(option.props().disabled).to.equal(hasGenre); - expect(option.text()).to.equal(displayName); - expect(option.props().onClick).to.equal(handleGenreSelect); - }); - }); - }); - - describe("behavior", () => { - it("shows multiple select with subgenres when genre is selected", () => { - expect(wrapper.state("genre")).to.equal(null); - expect(wrapper.state("subgenre")).to.equal(null); - - // click on genre without subgenres - let genre = wrapper.find("select[name='genre']").find("option").first(); - genre.simulate("click", { target: { value: genre.props().value } }); - expect(wrapper.state("genre")).to.equal(genre.props().value); - - let options = wrapper.find("select[name='subgenre']").find("option"); - expect(options.length).to.equal(0); - - // click on genre with subgenres - genre = wrapper - .find("select[name='genre']") - .find("option") - .findWhere((option) => option.props().value === "Fantasy"); - genre.simulate("click", { target: { value: genre.props().value } }); - expect(wrapper.state("genre")).to.equal(genre.props().value); - - options = wrapper.find("select[name='subgenre']").find("option"); - const handleSubgenreSelect = (wrapper.instance() as any) - .handleSubgenreSelect; - const subgenres = genreData["Fiction"]["Fantasy"].subgenres; - - expect(options.length).to.equal(subgenres.length); - options.forEach((option, i) => { - const name = subgenres[i]; - const hasGenre = bookGenres.indexOf(name) !== -1; - expect(option.props().value).to.equal(name); - expect(option.props().disabled).to.equal(hasGenre); - expect(option.text()).to.equal(name); - expect(option.props().onClick).to.equal(handleSubgenreSelect); - }); - - // click on subgenre - const subgenre = options.first(); - subgenre.simulate("click", { target: { value: subgenre.props().value } }); - expect(wrapper.state("subgenre")).to.equal(subgenre.props().value); - - // genreOptions changes due to change in fiction status - const newGenreOptions = Object.keys(genreData["Nonfiction"]).map( - (name) => genreData["Nonfiction"][name] - ); - wrapper.setProps({ genreOptions: newGenreOptions }); - options = wrapper.find("select[name='subgenre']").find("option"); - expect(options.length).to.equal(0); - }); - - it("shows add button only if genre is selected", () => { - let button = wrapper.find(Button); - expect(button.length).to.equal(0); - - wrapper.setState({ genre: "Women's Fiction" }); - button = wrapper.find(Button); - expect(button.length).to.equal(1); - }); - - it("adds genre", () => { - wrapper = mount( - - ); - wrapper.setState({ genre: "Science Fiction", subgenre: "Space Opera" }); - wrapper.find(Button).simulate("click"); - - expect(addGenre.callCount).to.equal(1); - expect(addGenre.args[0][0]).to.equal("Space Opera"); - }); - }); -}); diff --git a/src/components/__tests__/Lane-test.tsx b/src/components/__tests__/Lane-test.tsx deleted file mode 100644 index 51b2e7b508..0000000000 --- a/src/components/__tests__/Lane-test.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import { expect } from "chai"; -import { stub } from "sinon"; -import { LaneData } from "../../interfaces"; -import * as React from "react"; -import { mount } from "enzyme"; -import Lane from "../Lane"; -import { Link } from "react-router"; - -describe("Lane", () => { - let wrapper; - let renderLanes; - let toggleLaneVisibility; - const subsublaneData: LaneData = { - id: 3, - display_name: "subsublane 1", - visible: false, - count: 2, - sublanes: [], - custom_list_ids: [2], - inherit_parent_restrictions: false, - }; - const sublaneData: LaneData = { - id: 2, - display_name: "sublane 1", - visible: false, - count: 3, - sublanes: [subsublaneData], - custom_list_ids: [2], - inherit_parent_restrictions: false, - }; - const laneData: LaneData = { - id: 1, - display_name: "Top Lane 1", - visible: true, - count: 5, - sublanes: [sublaneData], - custom_list_ids: [1], - inherit_parent_restrictions: true, - }; - beforeEach(() => { - renderLanes = stub(); - toggleLaneVisibility = stub(); - wrapper = mount( - - ); - }); - - it("renders create sublane link", () => { - // The lane starts out expanded by default, so the create link is already showing. - let createSublane = wrapper - .find(Link) - .filterWhere((el) => el.find("a").hasClass("create-lane")); - expect(createSublane.length).to.equal(1); - expect(createSublane.prop("to")).to.equal( - "/admin/web/lanes/test_library/create/1" - ); - expect(createSublane.hasClass("disabled")).to.be.false; - - // The link is disabled if there are lane order changes. - wrapper.setProps({ orderChanged: true }); - createSublane = wrapper - .find(Link) - .filterWhere((el) => el.find("a").hasClass("create-lane")); - expect(createSublane.length).to.equal(1); - expect(createSublane.prop("to")).to.be.null; - expect(createSublane.hasClass("disabled")).to.be.true; - - // If the lane isn't expanded, the create link isn't shown. - wrapper.setState({ expanded: false }); - createSublane = wrapper - .find(Link) - .filterWhere((el) => el.find("a").hasClass("create-lane")); - expect(createSublane.length).to.equal(0); - }); - - it("renders edit link", () => { - // The lane starts out expanded by default, so the edit link is already showing. - let editSublane = wrapper - .find(Link) - .filterWhere((el) => el.find("a").hasClass("edit-lane")); - expect(editSublane.length).to.equal(1); - expect(editSublane.prop("to")).to.equal( - "/admin/web/lanes/test_library/edit/1" - ); - expect(editSublane.hasClass("disabled")).to.be.false; - - // The link is disabled if there are lane order changes. - wrapper.setProps({ lane: laneData, orderChanged: true }); - editSublane = wrapper - .find(Link) - .filterWhere((el) => el.find("a").hasClass("edit-lane")); - expect(editSublane.length).to.equal(1); - expect(editSublane.prop("to")).to.be.null; - expect(editSublane.hasClass("disabled")).to.be.true; - - // If the lane isn't expanded, the edit link isn't shown. - wrapper.setState({ expanded: false }); - editSublane = wrapper - .find(Link) - .filterWhere((el) => el.find("a").hasClass("edit-lane")); - expect(editSublane.length).to.equal(0); - }); - - it("shows a lane", () => { - // The lane is already showing. - let showButton = wrapper.find(".show-lane").hostNodes(); - expect(showButton.length).to.equal(0); - - wrapper.setState({ visible: false }); - showButton = wrapper.find(".show-lane").hostNodes(); - expect(showButton.length).to.equal(1); - expect(showButton.text()).to.contain("Hidden"); - expect(showButton.find("svg title").text()).to.equal("Hidden Icon"); - showButton.simulate("click"); - expect(wrapper.state()["visible"]).to.be.true; - - // If the lane has a hidden parent, it can't be shown. - wrapper.setProps({ parent: { visible: false } }); - wrapper.setState({ expanded: true, visible: false }); - showButton = wrapper.find(".show-lane").hostNodes(); - expect(showButton.length).to.equal(1); - expect(showButton.prop("disabled")).to.be.true; - // Clicking the show button doesn't do anything; it's disabled. - showButton.simulate("click"); - expect(wrapper.state()["visible"]).to.be.false; - - // If lane order has changed, all of the show buttons are disabled. - wrapper.setProps({ parent: null, orderChanged: true }); - wrapper.setState({ visible: false }); - showButton = wrapper.find(".show-lane").hostNodes(); - expect(showButton.length).to.equal(1); - expect(showButton.prop("disabled")).to.be.true; - // Clicking the show button doesn't do anything; it's disabled. - showButton.simulate("click"); - expect(wrapper.state()["visible"]).to.be.false; - }); - it("hides a lane", () => { - let hideButton = wrapper.find(".hide-lane").hostNodes(); - expect(hideButton.length).to.equal(1); - expect(hideButton.text()).to.contain("Visible"); - expect(hideButton.find("svg title").text()).to.equal("Visible Icon"); - hideButton.simulate("click"); - expect(wrapper.state()["visible"]).to.be.false; - - // If lane order has changed, all of the hide buttons are disabled. - wrapper.setProps({ orderChanged: true }); - wrapper.setState({ visible: true }); - hideButton = wrapper.find(".hide-lane").hostNodes(); - expect(hideButton.length).to.equal(1); - expect(hideButton.prop("disabled")).to.be.true; - // Clicking the hide button doesn't do anything; it's disabled. - hideButton.simulate("click"); - expect(wrapper.state()["visible"]).to.be.true; - }); -}); diff --git a/src/components/__tests__/LaneEditor-test.tsx b/src/components/__tests__/LaneEditor-test.tsx deleted file mode 100644 index b825da57a5..0000000000 --- a/src/components/__tests__/LaneEditor-test.tsx +++ /dev/null @@ -1,421 +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 LaneEditor from "../LaneEditor"; -import TextWithEditMode from "../TextWithEditMode"; -import EditableInput from "../EditableInput"; -import LaneCustomListsEditor from "../LaneCustomListsEditor"; -import * as navigate from "../../utils/navigate"; - -describe("LaneEditor", () => { - let wrapper; - let editLane; - let deleteLane; - let findParentOfLane; - let toggleLaneVisibility; - let navigateTo; - - const customListsData = [ - { id: 1, name: "list 1", entries: [], is_owner: true, is_shared: false }, - ]; - - const laneData = { - id: 1, - display_name: "lane", - visible: true, - count: 5, - sublanes: [ - { - id: 2, - display_name: "sublane", - visible: true, - count: 3, - sublanes: [], - custom_list_ids: [1], - inherit_parent_restrictions: false, - }, - ], - custom_list_ids: [1], - inherit_parent_restrictions: true, - }; - - beforeEach(() => { - editLane = stub().returns(new Promise((resolve) => resolve())); - deleteLane = stub().returns(new Promise((resolve) => resolve())); - stub().returns(new Promise((resolve) => resolve())); - findParentOfLane = stub().returns(laneData); - toggleLaneVisibility = stub(); - wrapper = shallow( - - ); - - navigateTo = stub(navigate, "navigateTo"); - }); - - afterEach(() => { - navigateTo.restore(); - }); - - it("shows lane name", () => { - const name = wrapper.find(TextWithEditMode); - expect(name.length).to.equal(1); - expect(name.props().text).to.equal("lane"); - expect(name.props().placeholder).to.equal("name"); - }); - - it("shows lane id", () => { - const laneId = wrapper.find(".lane-editor-header h4"); - expect(laneId.length).to.be.at.least(1); - expect(laneId.at(0).text()).to.contain("1"); - }); - - it("shows visibility status", () => { - let info = wrapper.find(".lane-details"); - expect(info.length).to.equal(1); - expect(info.text()).to.contain("visible"); - expect(info.text()).not.to.contain("hidden"); - - const hiddenLane = Object.assign({}, laneData, { visible: false }); - wrapper.setProps({ lane: hiddenLane }); - info = wrapper.find(".lane-details"); - expect(info.length).to.equal(1); - expect(info.text()).not.to.contain("visible"); - expect(info.text()).to.contain("hidden"); - - wrapper.setProps({ findParentOfLane: stub().returns(hiddenLane) }); - info = wrapper.find(".lane-details"); - expect(info.length).to.equal(1); - expect(info.text()).not.to.contain("visible"); - expect(info.text()).to.contain("hidden"); - expect(info.text()).to.contain("parent"); - }); - - it("shows parent of new lane", () => { - wrapper.setProps({ lane: null, findParentOfLane: stub().returns(null) }); - let parentInfo = wrapper.find(".lane-details"); - expect(parentInfo.length).to.equal(1); - expect(parentInfo.text()).to.contain("top-level lane"); - - wrapper.setProps({ findParentOfLane: stub().returns(laneData) }); - parentInfo = wrapper.find(".lane-details"); - expect(parentInfo.length).to.equal(1); - expect(parentInfo.text()).to.contain("sublane of lane"); - }); - - it("doesn't show the inherit parent restrictions setting on a new lane", () => { - wrapper.setProps({ findParentOfLane: stub().returns(null) }); - const input = wrapper.find(EditableInput); - expect(input.length).to.equal(0); - }); - - it("shows and changes inherit parent restrictions setting on a child lane", () => { - wrapper = mount( - - ); - - let input = wrapper.find(".lane-details").find(EditableInput); - expect(input.props().checked).to.be.true; - expect(input.props().label).to.contain("restrictions"); - - const onChange = input.props().onChange; - onChange(); - wrapper.update(); - input = wrapper.find(".lane-details").find(EditableInput); - expect(input.props().checked).to.be.false; - }); - - it("shows custom lists editor", () => { - const listsEditor = wrapper.find(LaneCustomListsEditor); - expect(listsEditor.length).to.equal(1); - expect(listsEditor.props().allCustomLists).to.deep.equal(customListsData); - expect(listsEditor.props().filteredCustomLists).to.deep.equal( - customListsData - ); - expect(listsEditor.props().customListIds).to.deep.equal([1]); - }); - - it("filters the lists passed to the custom lists editor's filteredCustomLists prop", () => { - const allCustomListsData = [ - { id: 1, name: "list 1", entries: [], is_owner: true, is_shared: false }, - { id: 2, name: "list 2", entries: [], is_owner: false, is_shared: true }, - ]; - - wrapper.setProps({ - customLists: allCustomListsData, - }); - - let listsEditor; - - listsEditor = wrapper.find(LaneCustomListsEditor); - - // The default filter is owned. - expect(listsEditor.props().filteredCustomLists).to.deep.equal([ - allCustomListsData[0], - ]); - - listsEditor.invoke("changeFilter")("shared-in"); - listsEditor = wrapper.find(LaneCustomListsEditor); - - expect(listsEditor.props().filteredCustomLists).to.deep.equal([ - allCustomListsData[1], - ]); - - listsEditor.invoke("changeFilter")(""); - listsEditor = wrapper.find(LaneCustomListsEditor); - - expect(listsEditor.props().filteredCustomLists).to.deep.equal( - allCustomListsData - ); - }); - - it("deletes lane", () => { - wrapper = mount( - - ); - let deleteButton = wrapper.find(Button).at(1); - expect(deleteButton.hasClass("delete-lane")).to.be.true; - deleteButton.simulate("click"); - - expect(deleteLane.callCount).to.equal(1); - expect(deleteLane.args[0][0]).to.deep.equal(laneData); - - // The delete button doesn't show for a new lane. - wrapper.setProps({ lane: null }); - deleteButton = wrapper.find(".delete-lane"); - expect(deleteButton.length).to.equal(0); - }); - - it("shows lane", () => { - wrapper = mount( - - ); - const toggle = stub().returns(() => - wrapper.setProps({ - lane: { ...laneData, ...{ visible: !laneData.visible } }, - }) - ); - wrapper.setProps({ toggleLaneVisibility: toggle }); - - let showButton = wrapper.find(Button).at(2); - expect(showButton.hasClass("show-lane")).to.be.false; - - const hiddenLane = Object.assign({}, laneData, { visible: false }); - wrapper.setProps({ lane: hiddenLane }); - showButton = wrapper.find(Button).at(2); - expect(showButton.hasClass("show-lane")).to.be.true; - - showButton.simulate("click"); - - expect(toggle.callCount).to.equal(1); - expect(toggle.args[0][0]).to.deep.equal(hiddenLane); - - const hiddenParent = Object.assign({}, hiddenLane, { - id: 5, - display_name: "parent", - }); - wrapper.setProps({ findParentOfLane: stub().returns(hiddenParent) }); - showButton = wrapper.find(".show-lane"); - expect(showButton.length).to.equal(0); - }); - - it("hides lane", () => { - wrapper = mount( - - ); - const toggle = stub().returns(() => - wrapper.setProps({ - lane: { ...laneData, ...{ visible: !laneData.visible } }, - }) - ); - wrapper.setProps({ toggleLaneVisibility: toggle }); - - let hideButton = wrapper.find(Button).at(2); - expect(hideButton.hasClass("hide-lane")).to.be.true; - - hideButton.simulate("click"); - expect(toggle.callCount).to.equal(1); - expect(toggle.args[0][0]).to.deep.equal(laneData); - - const hiddenLane = Object.assign({}, laneData, { visible: false }); - wrapper.setProps({ findParentOfLane: stub().returns(hiddenLane) }); - hideButton = wrapper.find(".hide-lane"); - expect(hideButton.length).to.equal(0); - }); - - it("saves lane", () => { - wrapper = mount( - - ); - const getTextStub = stub(TextWithEditMode.prototype, "getText").returns( - "new lane name" - ); - const getCustomListIdsStub = stub( - LaneCustomListsEditor.prototype, - "getCustomListIds" - ).returns([1, 2]); - (wrapper.instance() as LaneEditor).changeInheritParentRestrictions(); - - // .hostNodes() retrieves only the DOM node instead of the DOM node - // and the React class component. - const saveButton = wrapper.find(".save-lane").hostNodes(); - saveButton.simulate("click"); - - expect(editLane.callCount).to.equal(1); - const formData = editLane.args[0][0]; - expect(formData.get("id")).to.equal("1"); - expect(formData.get("parent_id")).to.equal("1"); - expect(formData.get("display_name")).to.equal("new lane name"); - expect(formData.get("custom_list_ids")).to.equal(JSON.stringify([1, 2])); - expect(formData.get("inherit_parent_restrictions")).to.equal("false"); - - getTextStub.restore(); - getCustomListIdsStub.restore(); - }); - - it("navigates to edit page after a new lane is created", async () => { - wrapper = mount( - - ); - const getTextStub = stub(TextWithEditMode.prototype, "getText").returns( - "new lane name" - ); - const getCustomListIdsStub = stub( - LaneCustomListsEditor.prototype, - "getCustomListIds" - ).returns([1, 2]); - - const saveButton = wrapper.find(".save-lane").hostNodes(); - saveButton.simulate("click"); - expect(editLane.callCount).to.equal(1); - getTextStub.restore(); - getCustomListIdsStub.restore(); - - wrapper.setProps({ responseBody: 5 }); - // Let the call stack clear so the callback after editLane will run. - const pause = (): Promise => { - return new Promise((resolve) => setTimeout(resolve, 0)); - }; - await pause(); - expect(navigateTo.callCount).to.equal(1); - expect(navigateTo.args[0][0]).to.equal("/admin/web/lanes/library/edit/5"); - }); - - it("cancels changes", () => { - const nameResetStub = stub(TextWithEditMode.prototype, "reset"); - const customListsResetStub = stub(LaneCustomListsEditor.prototype, "reset"); - - wrapper = mount( - - ); - - // the cancel button isn't shown when there are no changes. - let cancelButton = wrapper.find(".cancel-changes").hostNodes(); - expect(cancelButton.length).to.equal(0); - - (wrapper.instance() as LaneEditor).changeName("new name"); - wrapper.update(); - cancelButton = wrapper.find(".cancel-changes").hostNodes(); - expect(cancelButton.length).to.equal(1); - cancelButton.simulate("click"); - - expect(nameResetStub.callCount).to.equal(1); - expect(customListsResetStub.callCount).to.equal(1); - - (wrapper.instance() as LaneEditor).changeName(laneData.display_name); - wrapper.update(); - cancelButton = wrapper.find(".cancel-changes").hostNodes(); - expect(cancelButton.length).to.equal(0); - - (wrapper.instance() as LaneEditor).changeCustomLists([1, 2]); - wrapper.update(); - cancelButton = wrapper.find(".cancel-changes").hostNodes(); - expect(cancelButton.length).to.equal(1); - cancelButton.simulate("click"); - - expect(nameResetStub.callCount).to.equal(2); - expect(customListsResetStub.callCount).to.equal(2); - - (wrapper.instance() as LaneEditor).changeCustomLists( - laneData.custom_list_ids - ); - wrapper.update(); - cancelButton = wrapper.find(".cancel-changes").hostNodes(); - expect(cancelButton.length).to.equal(0); - - (wrapper.instance() as LaneEditor).changeInheritParentRestrictions(); - wrapper.update(); - cancelButton = wrapper.find(".cancel-changes").hostNodes(); - expect(cancelButton.length).to.equal(1); - cancelButton.simulate("click"); - - expect(nameResetStub.callCount).to.equal(3); - expect(customListsResetStub.callCount).to.equal(3); - cancelButton = wrapper.find(".cancel-changes").hostNodes(); - expect(cancelButton.length).to.equal(0); - - nameResetStub.restore(); - customListsResetStub.restore(); - }); -}); diff --git a/src/components/__tests__/Lanes-test.tsx b/src/components/__tests__/Lanes-test.tsx deleted file mode 100644 index 821a4e9e9c..0000000000 --- a/src/components/__tests__/Lanes-test.tsx +++ /dev/null @@ -1,508 +0,0 @@ -import { expect } from "chai"; -import { stub } from "sinon"; - -import * as React from "react"; -import { shallow, mount } from "enzyme"; -import { Droppable } from "react-beautiful-dnd"; - -import { Lanes } from "../Lanes"; -import ErrorMessage from "../ErrorMessage"; -import LoadingIndicator from "@thepalaceproject/web-opds-client/lib/components/LoadingIndicator"; -import LaneEditor from "../LaneEditor"; -import EditableInput from "../EditableInput"; -import { LaneData } from "../../interfaces"; - -describe("Lanes", () => { - let wrapper; - let fetchLanes; - let fetchCustomLists; - let editLane; - let deleteLane; - let showLane; - let hideLane; - let resetLanes; - let changeLaneOrder; - - const customListsData = [ - { - id: 1, - name: "list 1", - entries: [], - is_owner: true, - is_shared: false, - }, - { - id: 2, - name: "list 2", - entries: [{ pwid: "1", title: "title", authors: [] }], - is_owner: true, - is_shared: false, - }, - ]; - - const subsublaneData: LaneData = { - id: 3, - display_name: "sublane 3", - visible: false, - count: 2, - sublanes: [], - custom_list_ids: [2], - inherit_parent_restrictions: false, - }; - const sublaneData: LaneData = { - id: 2, - display_name: "sublane 2", - visible: false, - count: 3, - sublanes: [subsublaneData], - custom_list_ids: [2], - inherit_parent_restrictions: false, - }; - let lanesData: LaneData[] = [ - { - id: 1, - display_name: "lane 1", - visible: true, - count: 5, - sublanes: [sublaneData], - custom_list_ids: [1], - inherit_parent_restrictions: true, - }, - { - id: 4, - display_name: "lane 4", - visible: true, - count: 1, - sublanes: [], - custom_list_ids: [], - inherit_parent_restrictions: false, - }, - ]; - - const mountWrapper = () => { - wrapper = mount( - - ); - }; - - const getTopLevelLanes = () => { - const sidebar = wrapper.find(".lanes-sidebar"); - const topLevelLanes = sidebar.find("ul.droppable").at(0).children(); - return topLevelLanes; - }; - - const getDroppableById = (id) => { - return wrapper - .find(Droppable) - .filterWhere((node) => node.props().droppableId === id); - }; - - beforeEach(() => { - fetchLanes = stub(); - fetchCustomLists = stub(); - editLane = stub().returns( - new Promise((resolve) => resolve()) - ); - deleteLane = stub().returns( - new Promise((resolve) => resolve()) - ); - showLane = stub().returns( - new Promise((resolve) => resolve()) - ); - hideLane = stub().returns( - new Promise((resolve) => resolve()) - ); - resetLanes = stub().returns( - new Promise((resolve) => resolve()) - ); - changeLaneOrder = stub().returns( - new Promise((resolve) => resolve()) - ); - - wrapper = shallow( - - ); - }); - - it("renders error message from a bad form submission", () => { - let error = wrapper.find(ErrorMessage); - expect(error.length).to.equal(0); - - wrapper.setProps({ - formError: { status: 500, response: "Error", url: "url" }, - }); - error = wrapper.find(ErrorMessage); - expect(error.length).to.equal(1); - }); - - it("renders error message from loading error", () => { - 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("edits a lane", () => { - const testData = new (window as any).FormData(); - (wrapper.instance() as Lanes).editLane(testData); - expect(editLane.callCount).to.equal(1); - expect(editLane.args[0][0]).to.equal(testData); - expect(fetchLanes.callCount).to.equal(1); - }); - - it("deletes a lane", () => { - const confirmStub = stub(window, "confirm").returns(false); - - (wrapper.instance() as Lanes).deleteLane(sublaneData); - expect(deleteLane.callCount).to.equal(0); - - confirmStub.returns(true); - (wrapper.instance() as Lanes).deleteLane(sublaneData); - expect(deleteLane.callCount).to.equal(1); - expect(deleteLane.args[0][0]).to.equal("2"); - expect(fetchLanes.callCount).to.equal(1); - - confirmStub.restore(); - }); - - it("renders save and reset order if order has changed", () => { - mountWrapper(); - let orderInfo = wrapper.find(".order-change-info"); - expect(orderInfo.length).to.equal(0); - - wrapper.setState({ orderChanged: true }); - orderInfo = wrapper.find(".order-change-info"); - expect(orderInfo.length).to.equal(1); - - const save = orderInfo.find(".save-lane-order-changes").hostNodes(); - expect(save.length).to.equal(1); - const reset = orderInfo.find(".cancel-lane-order-changes").hostNodes(); - expect(reset.length).to.equal(1); - }); - - it("renders create form by default", () => { - expect(wrapper.instance().props.editOrCreate).to.equal("create"); - let editor = wrapper.find(LaneEditor); - expect(editor.length).to.equal(1); - expect(editor.props().library).to.equal("library"); - expect(editor.props().findParentOfLane()).to.equal(null); - expect(editor.props().customLists).to.equal(customListsData); - expect(editor.props().editLane).to.be.ok; - - wrapper.setProps({ identifier: "2" }); - editor = wrapper.find(LaneEditor); - expect(editor.length).to.equal(1); - expect(editor.props().library).to.equal("library"); - expect(editor.props().findParentOfLane()).to.equal(sublaneData); - expect(editor.props().customLists).to.equal(customListsData); - expect(editor.props().editLane).to.be.ok; - }); - - it("renders edit form", () => { - wrapper.setProps({ editOrCreate: "edit", identifier: "2" }); - const editor = wrapper.find(LaneEditor); - expect(editor.length).to.equal(1); - expect(editor.props().library).to.equal("library"); - expect(editor.props().lane).to.deep.equal(sublaneData); - expect(editor.props().findParentOfLane(editor.props().lane)).to.deep.equal( - lanesData[0] - ); - expect(editor.props().customLists).to.deep.equal(customListsData); - expect(editor.props().editLane).to.be.ok; - expect(editor.props().deleteLane).to.be.ok; - expect(editor.props().toggleLaneVisibility).to.be.ok; - }); - - it("renders reset form", () => { - let resetForm = wrapper.find(".reset"); - expect(resetForm.length).to.equal(0); - - wrapper.setProps({ editOrCreate: "reset" }); - resetForm = wrapper.find(".reset"); - expect(resetForm.length).to.equal(1); - expect(resetForm.text()).to.contain("cannot be undone"); - }); - - it("resets lanes", () => { - mountWrapper(); - wrapper.setProps({ editOrCreate: "reset" }); - - // mock typing in the 'RESET' confirmation input box - const editableInputStub = stub(EditableInput.prototype, "getValue").returns( - "DO NOT RESET" - ); - - const resetButton = wrapper.find(".reset-button").hostNodes(); - resetButton.simulate("click"); - expect(resetLanes.callCount).to.equal(0); - - editableInputStub.returns("RESET"); - wrapper.setState({ canReset: true }); - resetButton.simulate("click"); - expect(resetLanes.callCount).to.equal(1); - expect(fetchLanes.callCount).to.equal(2); - - editableInputStub.restore(); - }); - - it("updates the state to determine whether the lanes can be reset", () => { - mountWrapper(); - wrapper.setProps({ editOrCreate: "reset" }); - expect(wrapper.state()["canReset"]).to.be.false; - let button = wrapper.find(".reset button"); - expect(button.prop("disabled")).to.be.true; - const input = wrapper.find(".reset input"); - input.getDOMNode().value = "RESET"; - input.simulate("change"); - - button = wrapper.find(".reset button"); - expect(wrapper.state()["canReset"]).to.be.true; - expect(button.prop("disabled")).not.to.be.true; - }); - - it("prevents dragging a lane out of its parent", () => { - const newSublaneData: LaneData = { - id: 5, - display_name: "sublane 5", - visible: true, - count: 6, - sublanes: [], - custom_list_ids: [2], - inherit_parent_restrictions: false, - }; - const newSubsublaneData: LaneData = { - id: 6, - display_name: "sublane 6", - visible: true, - count: 6, - sublanes: [], - custom_list_ids: [2], - inherit_parent_restrictions: false, - }; - lanesData = [ - { - id: 1, - display_name: "lane 1", - visible: true, - count: 5, - sublanes: [ - { ...sublaneData, sublanes: [subsublaneData, newSubsublaneData] }, - newSublaneData, - ], - custom_list_ids: [1], - inherit_parent_restrictions: true, - }, - { - id: 4, - display_name: "lane 4", - visible: true, - count: 1, - sublanes: [], - custom_list_ids: [], - inherit_parent_restrictions: false, - }, - ]; - mountWrapper(); - - // simulate starting a drag of lane1 - (wrapper.instance() as Lanes).drag({ - draggableId: "1", - source: { - droppableId: "top", - }, - }); - wrapper.update(); - - // dropping should be disabled everywhere except the top-level lane - let topDroppable = getDroppableById("top"); - expect(topDroppable.props().isDropDisabled).to.be.false; - let lane1Droppable = getDroppableById("1"); - expect(lane1Droppable.props().isDropDisabled).to.be.true; - - // sublane 2 is collapsed so it isn't droppable - const topLevelLanes = getTopLevelLanes(); - const lane1 = topLevelLanes.at(0).find(".lane-parent").at(0); - const sublane2 = lane1.find("li .lane-parent").at(0); - const sublane2Expand = sublane2.find(".expand-button").hostNodes(); - sublane2Expand.simulate("click"); - let sublane2Droppable = getDroppableById("2"); - expect(sublane2Droppable.props().isDropDisabled).to.be.true; - - // now simulate dragging sublane 2 - (wrapper.instance() as Lanes).drag({ - draggableId: "2", - source: { - droppableId: "1", - }, - }); - wrapper.update(); - - topDroppable = getDroppableById("top"); - lane1Droppable = getDroppableById("1"); - sublane2Droppable = getDroppableById("2"); - expect(topDroppable.props().isDropDisabled).to.be.true; - expect(lane1Droppable.props().isDropDisabled).to.be.false; - expect(sublane2Droppable.props().isDropDisabled).to.be.true; - }); - - it("drags a top-level lane", () => { - mountWrapper(); - // simulate starting a drag of lane1 - (wrapper.instance() as Lanes).drag({ - draggableId: "1", - droppableId: "top", - }); - wrapper.update(); - - // simulate dropping below lane 4 - (wrapper.instance() as Lanes).drag({ - draggableId: null, - droppableId: null, - lanes: [lanesData[1], lanesData[0]], - orderChanged: true, - }); - wrapper.update(); - - const topLevelLanes = getTopLevelLanes(); - const lane4 = topLevelLanes.at(0); - const lane1 = topLevelLanes.at(1); - expect(lane1.text()).to.contain("lane 1"); - expect(lane4.text()).to.contain("lane 4"); - }); - - it("drags a sublane", () => { - const newSublaneData: LaneData = { - id: 5, - display_name: "sublane 5", - visible: true, - count: 6, - sublanes: [], - custom_list_ids: [2], - inherit_parent_restrictions: false, - }; - lanesData = [ - { - id: 1, - display_name: "lane 1", - visible: true, - count: 5, - sublanes: [sublaneData, newSublaneData], - custom_list_ids: [1], - inherit_parent_restrictions: true, - }, - { - id: 4, - display_name: "lane 4", - visible: true, - count: 1, - sublanes: [], - custom_list_ids: [], - inherit_parent_restrictions: false, - }, - ]; - const rearrangedLane = { - ...lanesData[1], - ...{ sublanes: [newSublaneData, sublaneData] }, - }; - mountWrapper(); - - // simulate starting a drag of sublane 5 - (wrapper.instance() as Lanes).drag({ - draggableId: "5", - droppableId: "1", - }); - wrapper.update(); - - // simulate dropping above sublane 2 - (wrapper.instance() as Lanes).drag({ - draggableId: null, - droppableId: null, - orderChanged: true, - lanes: [rearrangedLane, lanesData[1]], - }); - wrapper.update(); - - const topLevelLanes = getTopLevelLanes(); - const lane1 = topLevelLanes.at(0).find(".lane-parent").at(0); - const sublanes = lane1.find("li .lane-parent"); - const sublane5 = sublanes.at(0); - const sublane2 = sublanes.at(1); - expect(sublane2.text()).to.contain("sublane 2"); - expect(sublane5.text()).to.contain("sublane 5"); - }); - - it("saves lane order changes", () => { - mountWrapper(); - wrapper.setState({ - orderChanged: true, - lanes: [lanesData[1], lanesData[0]], - }); - - const saveOrderButton = wrapper - .find(".order-change-info .save-lane-order-changes") - .hostNodes(); - saveOrderButton.simulate("click"); - expect(changeLaneOrder.callCount).to.equal(1); - expect(changeLaneOrder.args[0][0]).to.deep.equal([ - lanesData[1], - lanesData[0], - ]); - expect(fetchLanes.callCount).to.equal(2); - }); - - it("resets lane order changes", () => { - mountWrapper(); - wrapper.setState({ - orderChanged: true, - lanes: [lanesData[1], lanesData[0]], - }); - - const cancelOrderChangesButton = wrapper - .find(".order-change-info .cancel-lane-order-changes") - .hostNodes(); - cancelOrderChangesButton.simulate("click"); - expect(wrapper.state().orderChanged).to.equal(false); - expect(wrapper.state().lanes).to.deep.equal(lanesData); - }); -}); diff --git a/src/components/__tests__/LanesSidebar-test.tsx b/src/components/__tests__/LanesSidebar-test.tsx deleted file mode 100644 index b19977316b..0000000000 --- a/src/components/__tests__/LanesSidebar-test.tsx +++ /dev/null @@ -1,518 +0,0 @@ -import { expect } from "chai"; -import { stub } from "sinon"; -import { LaneData } from "../../interfaces"; -import * as React from "react"; -import { mount } from "enzyme"; - -import LanesSidebar from "../LanesSidebar"; -import { Link } from "react-router"; - -describe("LanesSidebar", () => { - let wrapper; - let drag; - let findLaneForIdentifier; - let findParentOfLane; - const subsublaneData: LaneData = { - id: 3, - display_name: "SubSublane 1", - visible: false, - count: 2, - sublanes: [], - custom_list_ids: [2], - inherit_parent_restrictions: false, - }; - const sublaneData: LaneData = { - id: 2, - display_name: "Sublane 1", - visible: false, - count: 3, - sublanes: [subsublaneData], - custom_list_ids: [2], - inherit_parent_restrictions: false, - }; - let lanesData: LaneData[]; - - // Returns all the list item elements from the first list in the DOM - // that is a droppable list. - const getTopLevelLanes = () => { - return wrapper.find("ul.droppable").at(0).children(); - }; - // Returns all the children lanes (including grandchildren) from the given parent lane. - const allChildren = (lane) => { - return lane.find("li .lane-parent"); - }; - // Returns all the draggable children lanes - // (including grandchildren) from the given parent lane. - const draggableChildren = (lane) => { - return lane.find("li .lane-parent .draggable"); - }; - - beforeEach(() => { - lanesData = [ - { - id: 1, - display_name: "Top Lane 1", - visible: true, - count: 5, - sublanes: [sublaneData], - custom_list_ids: [1], - inherit_parent_restrictions: true, - }, - { - id: 4, - display_name: "Top Lane 2", - visible: true, - count: 1, - sublanes: [], - custom_list_ids: [], - inherit_parent_restrictions: false, - }, - ]; - drag = stub(); - findLaneForIdentifier = stub().returns(lanesData[1]); - findParentOfLane = stub().returns(null); - wrapper = mount( - - ); - }); - it("renders create top-level lane link", () => { - let create = wrapper.find(".lanes-sidebar > div").find(Link).at(0); - expect(create.render().hasClass("create-lane")).to.be.true; - expect(create.prop("to")).to.equal("/admin/web/lanes/library/create"); - - // the link is disabled if there are lane order changes - wrapper.setProps({ orderChanged: true }); - create = wrapper.find(".lanes-sidebar > div").find(Link).at(0); - expect(create.render().hasClass("create-lane")).to.be.true; - expect(create.prop("to")).to.be.null; - expect(create.render().hasClass("disabled")).to.be.true; - }); - it("renders reset link", () => { - let reset = wrapper.find(".lanes-sidebar > div").find(Link).at(1); - expect(reset.render().hasClass("reset-lane")).to.be.true; - expect(reset.prop("to")).to.equal("/admin/web/lanes/library/reset"); - - // the link is disabled if there are lane order changes - wrapper.setProps({ orderChanged: true }); - reset = wrapper.find(".lanes-sidebar > div").find(Link).at(1); - expect(reset.render().hasClass("reset-lane")).to.be.true; - expect(reset.prop("to")).to.be.null; - expect(reset.render().hasClass("disabled")).to.be.true; - }); - - it("renders active lane", () => { - let topLevelLanes = getTopLevelLanes(); - // The first div in each li is the main DOM element to test. - // Using the `div` for testing because that's the main DOM element - // that contains the interactive classes. - let topLane1 = topLevelLanes.at(0).find("div").at(0); - let topLane2 = topLevelLanes.at(1).find("div").at(0); - // This does the same as above but it's for a sublane, - // so the sublane of the parent lane. - // This now replaces `.find(Lane).find("> div"); as the second - // query now returns a parsing error (can't start a selector with `>`). - let subLane1 = topLane1.find("li > div"); - - expect(topLane1.render().hasClass("active")).to.be.false; - expect(topLane2.render().hasClass("active")).to.be.false; - expect(subLane1.render().hasClass("active")).to.be.false; - - wrapper.setProps({ identifier: "1" }); - topLevelLanes = getTopLevelLanes(); - topLane1 = topLevelLanes.at(0).find("div").at(0); - topLane2 = topLevelLanes.at(1).find("div").at(0); - subLane1 = topLane1.find("li > div"); - expect(topLane1.render().hasClass("active")).to.be.true; - expect(topLane2.render().hasClass("active")).to.be.false; - expect(subLane1.render().hasClass("active")).to.be.false; - - wrapper.setProps({ identifier: "2" }); - topLevelLanes = getTopLevelLanes(); - topLane1 = topLevelLanes.at(0).find("div").at(0); - topLane2 = topLevelLanes.at(1).find("div").at(0); - subLane1 = topLane1.find("li > div"); - expect(topLane1.render().hasClass("active")).to.be.false; - expect(topLane2.render().hasClass("active")).to.be.false; - expect(subLane1.render().hasClass("active")).to.be.true; - }); - - it("drags and drops a top-level lane", () => { - const dragTopLane2 = { - draggableId: "4", - source: { index: 1, droppableId: "top" }, - }; - // pick up Top Lane 2 - wrapper.instance().onDragStart(dragTopLane2); - expect(drag.callCount).to.equal(1); - expect(drag.args[0][0]).to.eql({ draggableId: "4", draggingFrom: "top" }); - // drop it before Top Lane 1 - wrapper.instance().onDragEnd({ - ...dragTopLane2, - ...{ destination: { droppableId: "top", index: 0 } }, - }); - expect(drag.callCount).to.equal(2); - expect(drag.args[1][0]).to.eql({ - draggableId: null, - draggingFrom: null, - lanes: [lanesData[1], lanesData[0]], - orderChanged: true, - }); - }); - - it("drags and drops a sublane", () => { - const newSublane: LaneData = { - id: 5, - display_name: "Sublane 2", - visible: true, - count: 0, - inherit_parent_restrictions: false, - sublanes: [], - custom_list_ids: [], - }; - findLaneForIdentifier.returns(newSublane); - findParentOfLane.returns(lanesData[0]); - lanesData[0].sublanes = [sublaneData, newSublane]; - const dragNewSublane = { - draggableId: "5", - source: { index: 1, droppableId: "1" }, - }; - // pick up Sublane 2 - wrapper.instance().onDragStart(dragNewSublane); - expect(drag.callCount).to.equal(1); - expect(drag.args[0][0]).to.eql({ draggableId: "5", draggingFrom: "1" }); - // drop it before Sublane 1 - wrapper.instance().onDragEnd({ - ...dragNewSublane, - ...{ destination: { droppableId: "1", index: 0 } }, - }); - expect(drag.callCount).to.equal(2); - expect(drag.args[1][0].draggableId).to.be.null; - expect(drag.args[1][0].draggingFrom).to.be.null; - expect(drag.args[1][0].orderChanged).to.be.true; - expect(drag.args[1][0].lanes[0].sublanes).to.eql([newSublane, sublaneData]); - }); - - it("drops a lane back into its original position", () => { - wrapper.instance().onDragEnd({ - draggableId: "1", - source: { index: 0, droppableId: "top" }, - destination: { index: 0, droppableId: "top" }, - }); - expect(drag.callCount).to.equal(0); - }); - - it("renders and expands and collapses lanes and sublanes", () => { - const expectExpanded = (lane) => { - const collapse = lane - .find(".lane-info") - .at(0) - .find(".collapse-button") - .hostNodes(); - expect(collapse.length).to.equal(1); - const expand = lane - .find(".lane-info") - .at(0) - .find(".expand-button") - .hostNodes(); - expect(expand.length).to.equal(0); - return collapse; - }; - - const expectCollapsed = (lane) => { - const collapse = lane - .find(".lane-info") - .at(0) - .find(".collapse-button") - .hostNodes(); - expect(collapse.length).to.equal(0); - const expand = lane - .find(".lane-info") - .at(0) - .find(".expand-button") - .hostNodes(); - expect(expand.length).to.equal(1); - return expand; - }; - - let topLevelLanes = getTopLevelLanes(); - expect(topLevelLanes.length).to.equal(2); - let topLane1 = topLevelLanes.at(0).find("div").at(0); - const topLane2 = topLevelLanes.at(1).find("div").at(0); - expect(topLane1.text()).to.contain("Top Lane 1"); - expect(topLane2.text()).to.contain("Top Lane 2"); - - // both top-level lanes are expanded to start. - expectExpanded(topLane1); - expectExpanded(topLane2); - - // top lane 1 has one sublane which is collapsed - let subLane1 = topLane1.find("li > div"); - expect(subLane1.text()).to.contain("Sublane 1"); - expect(subLane1.length).to.equal(1); - const subLane1Expand = expectCollapsed(subLane1); - - // top lane 2 has no sublanes - // let topLane2Sublanes = topLane2.find("li > div"); - // let topLane2Draggables = topLane2.find(Droppable).find(Draggable); - const topLane2Sublanes = allChildren(topLane2); - const topLane2Draggables = draggableChildren(topLane2); - expect(topLane2Sublanes.length).to.equal(0); - expect(topLane2Draggables.length).to.equal(0); - - // sublane 1 has a sublane, but it's not shown since sublane 1 is collapsed. - let subSubLane = subLane1.find("li > div"); - expect(subSubLane.length).to.equal(0); - - // if we expand sublane 1, we can see its sublane below it. - subLane1Expand.simulate("click"); - topLevelLanes = getTopLevelLanes(); - topLane1 = topLevelLanes.at(0).find("div").at(0); - subLane1 = topLane1.find("li > div"); - const subLane1Collapse = expectExpanded(subLane1); - subSubLane = subLane1.find("li > div"); - expect(subSubLane.text()).to.contain("SubSublane 1"); - expectCollapsed(subSubLane); - - // if we collapse sublane 1, its sublane is hidden again. - subLane1Collapse.simulate("click"); - topLevelLanes = getTopLevelLanes(); - topLane1 = topLevelLanes.at(0).find("div").at(0); - subLane1 = topLane1.find("li > div"); - expectCollapsed(subLane1); - subSubLane = subLane1.find("li > div"); - expect(subSubLane.length).to.equal(0); - - // if we collapse lane 1, sublane 2 is hidden. - const topLane1Collapse = expectExpanded(topLane1); - topLane1Collapse.simulate("click"); - topLevelLanes = getTopLevelLanes(); - topLane1 = topLevelLanes.at(0).find("div").at(0); - const topLane1Expand = expectCollapsed(topLane1); - subLane1 = topLane1.find("li > div"); - expect(subLane1.length).to.equal(0); - - // if we expand lane 1, sublane 2 is shown again. - topLane1Expand.simulate("click"); - topLevelLanes = getTopLevelLanes(); - topLane1 = topLevelLanes.at(0).find("div").at(0); - expectExpanded(topLane1); - subLane1 = topLane1.find("li > div"); - expect(subLane1.length).to.equal(1); - }); - - it("renders draggable sublanes only if there's more than one", () => { - // lane structure: - // top - // / \ - // 1 4 - // / \ - // 2 5 - // / / \ - // 3 6 7 - // | - // 8 - // - // top, 1, and 5 are the only lanes with draggable sublanes. - // 1, 2, 4, 5, 6, and 7 should be draggable. - const sublaneData: LaneData = { - id: 2, - display_name: "sublane 2", - visible: false, - count: 3, - sublanes: [subsublaneData], - custom_list_ids: [2], - inherit_parent_restrictions: false, - }; - - const sublane8Data: LaneData = { - id: 8, - display_name: "sublane 8", - visible: true, - count: 6, - sublanes: [], - custom_list_ids: [2], - inherit_parent_restrictions: false, - }; - const sublane6Data: LaneData = { - id: 6, - display_name: "sublane 6", - visible: true, - count: 6, - sublanes: [sublane8Data], - custom_list_ids: [2], - inherit_parent_restrictions: false, - }; - const sublane7Data: LaneData = { - id: 7, - display_name: "sublane 7", - visible: true, - count: 6, - sublanes: [], - custom_list_ids: [2], - inherit_parent_restrictions: false, - }; - const sublane5Data: LaneData = { - id: 5, - display_name: "sublane 5", - visible: true, - count: 6, - sublanes: [sublane6Data, sublane7Data], - custom_list_ids: [2], - inherit_parent_restrictions: false, - }; - lanesData = [ - { - id: 1, - display_name: "lane 1", - visible: true, - count: 5, - sublanes: [sublaneData, sublane5Data], - custom_list_ids: [1], - inherit_parent_restrictions: true, - }, - { - id: 4, - display_name: "lane 4", - visible: true, - count: 1, - sublanes: [], - custom_list_ids: [], - inherit_parent_restrictions: false, - }, - ]; - - const expand = (lane) => { - lane.find(".expand-button").hostNodes().at(0).props().onClick(); - }; - const isDraggable = (lane) => { - return lane.find(".lane-info").at(0).hasClass("draggable"); - }; - - wrapper.setProps({ lanes: lanesData }); - // Updated props so update the component. - wrapper.update(); - - // top - // / \ - // 1 4 - - /** lane 1 */ - let lane1 = getTopLevelLanes().at(0).find(".lane-parent").at(0); - let lane1Children = allChildren(lane1); - expect(lane1.text()).to.contain("lane 1"); - expect(isDraggable(lane1)).to.be.true; - // Lane 1 has two children (Sublane 2 and Sublane 5), which are draggable. - expect(draggableChildren(lane1).length).to.equal(2); - expect(lane1Children.length).to.equal(2); - - /** lane 4 */ - const lane4 = getTopLevelLanes().at(1).find(".lane-parent").at(0); - expect(lane4.text()).to.contain("lane 4"); - expect(isDraggable(lane4)).to.be.true; - // Lane 4 doesn't have children. - expect(allChildren(lane4).length).to.equal(0); - expect(draggableChildren(lane4).length).to.equal(0); - - // top - // / \ - // 1 4 - // / \ - // 2 5 - - /** lane 2 */ - let sublane2 = lane1Children.at(0); - expect(isDraggable(sublane2)).to.be.true; - expand(sublane2); - // The component's DOM was updated so the component needs to be updated. - wrapper.update(); - - expect(sublane2.text()).to.contain("sublane 2"); - // Sublane 2 has a child (Sublane 3). - expect(allChildren(sublane2.render()).length).to.equal(1); - // But because it's the only one, it's not draggable. - expect(draggableChildren(sublane2.render()).length).to.equal(0); - - /** lane 5 */ - let sublane5 = lane1Children.at(1); - expect(isDraggable(sublane5)).to.be.true; - expand(sublane5); - wrapper.update(); - - expect(sublane5.text()).to.contain("sublane 5"); - // Sublane 5 has two children (Sublane 6 and Sublane 7). - expect(allChildren(sublane5.render()).length).to.equal(2); - // Both of them are draggable. - expect(draggableChildren(sublane5.render()).length).to.equal(2); - - // top - // / \ - // 1 4 - // / \ - // 2 5 - // / / \ - // 3 6 7 - - /** lane 3 */ - // There's a need to refetch the DOM elements after updating it. - lane1 = getTopLevelLanes().at(0).find(".lane-parent").at(0); - lane1Children = allChildren(lane1); - sublane2 = lane1Children.at(0); - const sublane3 = allChildren(sublane2); - - expect(isDraggable(sublane3)).to.be.false; - expect(sublane3.text()).to.contain("SubSublane 1"); - // Sublane 3 doesn't have children. - expect(allChildren(sublane3).length).to.equal(0); - - /** lane 6 */ - // All lane 1 children are expanded: - // at(1) corresponds to lane 3 and at(2) corresponds to lane 5 - sublane5 = lane1Children.at(2); - const sublane6 = allChildren(sublane5).at(0); - expect(isDraggable(sublane6)).to.be.true; - expand(sublane6); - wrapper.update(); - expect(sublane6.text()).to.contain("sublane 6"); - // Sublane 6 has a child (Sublane 8). - expect(allChildren(sublane6.render()).length).to.equal(1); - // But it's the only one, so it's not draggable. - expect(draggableChildren(sublane6.render()).length).to.equal(0); - - /** lane 7 */ - const sublane7 = allChildren(sublane5).at(1); - expect(isDraggable(sublane7)).to.be.true; - expand(sublane7); - expect(sublane7.text()).to.contain("sublane 7"); - // Sublane 7 doesn't have children. - expect(allChildren(sublane7.render()).length).to.equal(0); - expect(draggableChildren(sublane7.render()).length).to.equal(0); - - // top - // / \ - // 1 4 - // / \ - // 2 5 - // / / \ - // 3 6 7 - // | - // 8 - /** lane 8 */ - lane1 = getTopLevelLanes().at(0).find(".lane-parent").at(0); - lane1Children = allChildren(lane1); - // The fourth child is the 8th lane since all lanes are expanded. - const sublane8 = lane1Children.at(4); - expect(isDraggable(sublane8)).to.be.false; - expand(sublane8); - expect(sublane8.text()).to.contain("sublane 8"); - // Sublane 8 doesn't have children. - expect(allChildren(sublane8.render()).length).to.equal(0); - expect(draggableChildren(sublane8.render()).length).to.equal(0); - }); -}); diff --git a/tests/jest/components/BookCoverEditor.test.tsx b/tests/jest/components/BookCoverEditor.test.tsx new file mode 100644 index 0000000000..2f5d797ce5 --- /dev/null +++ b/tests/jest/components/BookCoverEditor.test.tsx @@ -0,0 +1,394 @@ +import * as React from "react"; +import { installFormDataShim } from "../testUtils/formDataShim"; +import { act, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import { renderWithProviders } from "../testUtils/withProviders"; +import buildStore from "../../../src/store"; +// Render the CONNECTED default export so that mapStateToProps / mapDispatchToProps +// are exercised: `preview`, `rightsStatuses`, `isFetching`, the fetch errors, and +// `bookAdminUrl` are all fed in through the Redux store, while `book`, `bookUrl`, +// `csrfToken`, and `refreshCatalog` are own props. +import BookCoverEditor from "../../../src/components/BookCoverEditor"; +import { BookData, RightsStatusData } from "../../../src/interfaces"; +// The reusable-components `Form` builds `new FormData(formElement)` on +// submit, which the unit jsdom env's undici FormData rejects; install the +// shared shim that reads the form's successful controls. +installFormDataShim(); +const rightsStatuses: RightsStatusData = { + "http://creativecommons.org/licenses/by/4.0/": { + allows_derivatives: true, + name: "Creative Commons Attribution (CC BY)", + open_access: true, + }, + "http://librarysimplified.org/terms/rights-status/in-copyright": { + allows_derivatives: false, + name: "In Copyright", + open_access: false, + }, + "https://creativecommons.org/licenses/by-nd/4.0": { + allows_derivatives: false, + name: "Creative Commons Attribution-NoDerivs (CC BY-ND)", + open_access: true, + }, +}; + +const bookData: BookData = { + id: "id", + title: "title", + coverUrl: "/cover", + changeCoverLink: { + href: "/change_cover", + rel: "http://librarysimplified.org/terms/rel/change_cover", + }, +}; + +describe("BookCoverEditor", () => { + let fetchSpy: jest.SpyInstance; + + // The connected component fetches the rights statuses on mount and posts the + // cover preview / edited cover. Serve those endpoints and answer anything else + // (e.g. the OPDS book re-fetch) benignly so nothing rejects after teardown. + beforeEach(() => { + fetchSpy = jest.spyOn(globalThis, "fetch").mockImplementation((async ( + url: string + ) => { + const u = String(url); + if (u.includes("rights_status")) { + return new Response(JSON.stringify(rightsStatuses), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (u.includes("preview_book_cover")) { + return new Response("image data", { status: 200 }); + } + return new Response("", { status: 200 }); + }) as any); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + // Each test gets a fresh store so state does not leak. `preSeed` runs before + // mount; note the component clears the preview on mount, so preview-slice state + // must be dispatched after render instead. + const renderEditor = ( + preSeed?: (store: ReturnType) => void + ) => { + const store = buildStore(); + preSeed?.(store); + const refreshCatalog = jest.fn().mockResolvedValue(undefined); + const utils = renderWithProviders( + , + { reduxProviderProps: { store } } + ); + return { store, refreshCatalog, ...utils }; + }; + + // The rights inputs only appear once the on-mount fetch resolves; awaiting them + // both settles that fetch and confirms the rights statuses were fetched. + const awaitMounted = () => + screen.findByText("License") as Promise; + + describe("rendering", () => { + it("shows the book title", async () => { + renderEditor(); + await awaitMounted(); + expect( + screen.getByRole("heading", { level: 2, name: "title" }) + ).toBeInTheDocument(); + }); + + it("shows the current cover", async () => { + const { container } = renderEditor(); + await awaitMounted(); + const cover = container.querySelector(".current-cover"); + expect(cover).toBeInTheDocument(); + expect(cover).toHaveAttribute("src", bookData.coverUrl); + expect(cover).toHaveAttribute("alt", "Current book cover"); + }); + + it("shows the cover URL and cover file inputs", async () => { + const { container } = renderEditor(); + await awaitMounted(); + + expect(screen.getByText("URL for cover image")).toBeInTheDocument(); + expect( + container.querySelector('input[name="cover_url"]') + ).toBeInTheDocument(); + + expect(screen.getByText("Or upload cover image")).toBeInTheDocument(); + const fileInput = container.querySelector('input[name="cover_file"]'); + expect(fileInput).toHaveAttribute("type", "file"); + expect(fileInput).toHaveAttribute("accept", "image/*"); + }); + + it("shows the title position select", async () => { + const { container } = renderEditor(); + await awaitMounted(); + + expect(screen.getByText("Title and Author Position")).toBeInTheDocument(); + const select = container.querySelector('select[name="title_position"]'); + expect(select).toBeInTheDocument(); + const options = select.querySelectorAll("option"); + expect(Array.from(options).map((o) => o.getAttribute("value"))).toEqual([ + "none", + "top", + "center", + "bottom", + ]); + }); + + it("shows the rights inputs", async () => { + const { container } = renderEditor(); + await awaitMounted(); + + const rightsSelect = container.querySelector( + 'select[name="rights_status"]' + ); + expect(rightsSelect).toBeInTheDocument(); + const options = Array.from(rightsSelect.querySelectorAll("option")); + expect(options).toHaveLength(3); + expect(options[0].getAttribute("value")).toBe( + "http://creativecommons.org/licenses/by/4.0/" + ); + expect(options[0].textContent).toBe( + "Creative Commons Attribution (CC BY)" + ); + expect(options[1].getAttribute("value")).toBe( + "http://librarysimplified.org/terms/rights-status/in-copyright" + ); + expect(options[1].textContent).toBe("In Copyright"); + expect(options[2].getAttribute("value")).toBe( + "http://librarysimplified.org/terms/rights-status/unknown" + ); + expect(options[2].textContent).toBe("Other"); + + expect(screen.getByText("Explanation of rights")).toBeInTheDocument(); + expect( + container.querySelector('textarea[name="rights_explanation"]') + ).toBeInTheDocument(); + }); + + it("shows the updating loader while fetching", async () => { + const { container } = renderEditor((store) => + store.dispatch({ type: "BOOK_COVER_REQUEST" }) + ); + await awaitMounted(); + + // Only the top loader (driven by `isFetching`) shows; the preview loader + // (driven by `isFetchingPreview`) does not. + const loaders = container.querySelectorAll(".updating-loader"); + expect(loaders).toHaveLength(1); + expect(loaders[0]).toHaveTextContent("Updating"); + expect(loaders[0]).not.toHaveTextContent("Updating Preview"); + }); + + it("shows the preview updating message", async () => { + const { store, container } = renderEditor(); + await awaitMounted(); + expect(container.querySelectorAll(".updating-loader")).toHaveLength(0); + + act(() => { + store.dispatch({ type: "PREVIEW_BOOK_COVER_REQUEST" }); + }); + + const loaders = container.querySelectorAll(".updating-loader"); + expect(loaders).toHaveLength(1); + expect(loaders[0]).toHaveTextContent("Updating Preview"); + }); + + it("shows the preview once it is loaded", async () => { + const { store, container } = renderEditor(); + await awaitMounted(); + expect(container.querySelector(".preview-cover")).toBeNull(); + + act(() => { + store.dispatch({ type: "PREVIEW_BOOK_COVER_LOAD", data: "image data" }); + }); + + const preview = container.querySelector(".preview-cover"); + expect(preview).toBeInTheDocument(); + expect(preview).toHaveAttribute("src", "image data"); + expect(preview).toHaveAttribute("alt", "Preview of new cover"); + }); + + it("shows a fetch error", async () => { + const { store } = renderEditor(); + await awaitMounted(); + expect(screen.queryByText(/Error:/)).not.toBeInTheDocument(); + + act(() => { + store.dispatch({ + type: "BOOK_COVER_FAILURE", + error: { status: 500, url: "url", response: "fetch failed" }, + }); + }); + + expect(screen.getByText("Error: fetch failed")).toBeInTheDocument(); + }); + + it("shows a preview fetch error", async () => { + const { store } = renderEditor(); + await awaitMounted(); + expect(screen.queryByText(/Error:/)).not.toBeInTheDocument(); + + act(() => { + store.dispatch({ + type: "PREVIEW_BOOK_COVER_FAILURE", + error: { status: 500, url: "url", response: "preview failed" }, + }); + }); + + expect(screen.getByText("Error: preview failed")).toBeInTheDocument(); + }); + + it("enables the save button only once there is a preview", async () => { + const { store } = renderEditor(); + await awaitMounted(); + + const save = screen.getByRole("button", { name: "Save this cover" }); + expect(save).toBeDisabled(); + + act(() => { + store.dispatch({ type: "PREVIEW_BOOK_COVER_LOAD", data: "image data" }); + }); + + expect( + screen.getByRole("button", { name: "Save this cover" }) + ).toBeEnabled(); + }); + }); + + describe("mount behavior", () => { + it("fetches the rights statuses on mount", async () => { + renderEditor(); + // The rights inputs only render after the statuses are fetched. + expect(await screen.findByText("License")).toBeInTheDocument(); + expect(fetchSpy).toHaveBeenCalledWith( + "/admin/rights_status", + expect.anything() + ); + }); + + it("clears any stale preview on mount", async () => { + const { store, container } = renderEditor((store) => + store.dispatch({ type: "PREVIEW_BOOK_COVER_LOAD", data: "stale" }) + ); + await awaitMounted(); + + // The stale preview was cleared during mount, so it never renders. + expect(container.querySelector(".preview-cover")).toBeNull(); + expect(store.getState().editor.bookCoverPreview.data).toBeNull(); + }); + }); + + describe("behavior", () => { + it("previews the cover when the inputs change and the button is clicked", async () => { + const user = userEvent.setup(); + const { container } = renderEditor(); + await awaitMounted(); + + await user.type( + container.querySelector('input[name="cover_url"]'), + "http://example.com" + ); + await user.click(screen.getByRole("button", { name: "Preview" })); + + // The preview arrives from the store and is displayed. + const preview = await screen.findByAltText("Preview of new cover"); + expect(preview).toHaveAttribute("src", "image data"); + + // The preview was posted with the entered cover URL and title position. + const previewCall = fetchSpy.mock.calls.find(([u]) => + String(u).includes("preview_book_cover") + ); + expect(previewCall).toBeTruthy(); + expect(previewCall[1].method).toBe("POST"); + expect(previewCall[1].body.get("cover_url")).toBe("http://example.com"); + expect(previewCall[1].body.get("title_position")).toBe("none"); + }); + + it("clears the preview when the inputs are emptied", async () => { + const user = userEvent.setup(); + const { store } = renderEditor(); + await awaitMounted(); + // Seed a preview so we can observe it being cleared. + act(() => { + store.dispatch({ type: "PREVIEW_BOOK_COVER_LOAD", data: "image data" }); + }); + expect(screen.getByAltText("Preview of new cover")).toBeInTheDocument(); + + // With no cover URL or file, submitting the preview form clears it. + await user.click(screen.getByRole("button", { name: "Preview" })); + + await waitFor(() => + expect( + screen.queryByAltText("Preview of new cover") + ).not.toBeInTheDocument() + ); + // No preview request was posted, since there was nothing to preview. + expect( + fetchSpy.mock.calls.some(([u]) => + String(u).includes("preview_book_cover") + ) + ).toBe(false); + }); + + it("saves the cover with the entered values", async () => { + const user = userEvent.setup(); + const { store, refreshCatalog } = renderEditor(); + await awaitMounted(); + + // A preview must exist for the save button to be enabled. + act(() => { + store.dispatch({ type: "PREVIEW_BOOK_COVER_LOAD", data: "image data" }); + }); + + await user.type( + document.querySelector('input[name="cover_url"]') as HTMLElement, + "http://example.com" + ); + await user.selectOptions( + document.querySelector('select[name="title_position"]') as HTMLElement, + "center" + ); + await user.selectOptions( + document.querySelector('select[name="rights_status"]') as HTMLElement, + "http://creativecommons.org/licenses/by/4.0/" + ); + await user.type( + document.querySelector( + 'textarea[name="rights_explanation"]' + ) as HTMLElement, + "explanation" + ); + + await user.click(screen.getByRole("button", { name: "Save this cover" })); + + // The edited cover is posted to the exact change-cover link with the form + // values, and on success the catalog is refreshed. + const editCall = fetchSpy.mock.calls.find( + ([u]) => String(u) === bookData.changeCoverLink.href + ); + expect(editCall).toBeTruthy(); + expect(editCall[1].method).toBe("POST"); + expect(editCall[1].body.get("cover_url")).toBe("http://example.com"); + expect(editCall[1].body.get("title_position")).toBe("center"); + expect(editCall[1].body.get("rights_status")).toBe( + "http://creativecommons.org/licenses/by/4.0/" + ); + expect(editCall[1].body.get("rights_explanation")).toBe("explanation"); + + await waitFor(() => expect(refreshCatalog).toHaveBeenCalledTimes(1)); + }); + }); +}); diff --git a/tests/jest/components/CatalogPage.test.tsx b/tests/jest/components/CatalogPage.test.tsx new file mode 100644 index 0000000000..408e0a412d --- /dev/null +++ b/tests/jest/components/CatalogPage.test.tsx @@ -0,0 +1,158 @@ +import * as React from "react"; +import { screen } from "@testing-library/react"; + +import { renderWithProviders } from "../testUtils/withProviders"; +import title from "../../../src/utils/title"; + +// CatalogPage does `require("@thepalaceproject/web-opds-client")` and mounts the OPDS +// Web Client, a heavy third-party render target that fetches on mount. Replace it +// with a marker that echoes the props CatalogPage hands it and the legacy +// `tab`/`library` child context CatalogPage supplies (read via `contextTypes`), so +// we can assert the wiring without the real catalog. +jest.mock("@thepalaceproject/web-opds-client", () => { + const React = require("react"); + const PropTypes = require("prop-types"); + const OPDSCatalogMock = (props, context) => ( +
+ ); + OPDSCatalogMock.contextTypes = { + tab: PropTypes.string, + library: PropTypes.func, + }; + return OPDSCatalogMock; +}); + +// When there is no library, CatalogPage renders WelcomePage, which mounts the real +// Header — that needs react-router v3 context and fetches on mount. Mock Header to a +// marker so the WelcomePage path stays self-contained. +jest.mock("../../../src/components/Header", () => ({ + __esModule: true, + default: function Header() { + return
; + }, +})); + +import CatalogPage from "../../../src/components/CatalogPage"; +import Header from "../../../src/components/Header"; +import BookDetailsContainer from "../../../src/components/BookDetailsContainer"; + +// The unit env has no `global.jsdom`, so compute the expected URLs from the real +// origin. +const origin = window.location.origin; + +describe("CatalogPage", () => { + const baseParams = { + collectionUrl: "library/collectionurl", + bookUrl: "library/bookurl", + tab: "tab", + }; + + it("renders the OPDS catalog with expanded collection and book URLs", () => { + renderWithProviders(); + + const catalog = screen.getByTestId("opds-catalog"); + expect(catalog).toHaveAttribute( + "data-collection-url", + `${origin}/library/collectionurl` + ); + expect(catalog).toHaveAttribute( + "data-book-url", + `${origin}/library/works/bookurl` + ); + }); + + it("forwards the admin header, book details container, breadcrumbs, and page-title template", () => { + renderWithProviders(); + + const catalog = screen.getByTestId("opds-catalog"); + expect(catalog).toHaveAttribute("data-header-name", Header.name); + expect(catalog).toHaveAttribute( + "data-book-details-container-name", + BookDetailsContainer.name + ); + expect(catalog).toHaveAttribute("data-has-compute-breadcrumbs", "true"); + // pageTitleTemplate prefers the book title over the collection title. + expect(catalog).toHaveAttribute("data-title-book", title("Book")); + expect(catalog).toHaveAttribute( + "data-title-collection", + title("Collection") + ); + }); + + it("expands a collection URL that already contains a query string", () => { + renderWithProviders( + + ); + + expect(screen.getByTestId("opds-catalog")).toHaveAttribute( + "data-collection-url", + `${origin}/library/collectionurl?samplequery=test` + ); + }); + + it("puts the tab into the child context", () => { + renderWithProviders(); + expect(screen.getByTestId("opds-catalog")).toHaveAttribute( + "data-tab", + "tab" + ); + }); + + it("derives the child-context library from the collection URL", () => { + renderWithProviders(); + expect(screen.getByTestId("opds-catalog")).toHaveAttribute( + "data-library", + "library" + ); + }); + + it("derives the child-context library from the book URL when there is no collection URL", () => { + renderWithProviders( + + ); + expect(screen.getByTestId("opds-catalog")).toHaveAttribute( + "data-library", + "library" + ); + }); + + it("renders the welcome page when there is no library", () => { + renderWithProviders( + + ); + + expect(screen.queryByTestId("opds-catalog")).not.toBeInTheDocument(); + expect( + screen.getByText(/Welcome to the Circulation Admin Interface!/) + ).toBeInTheDocument(); + }); + + it("renders the footer", () => { + const { container } = renderWithProviders( + + ); + expect(container.querySelector("footer")).toBeInTheDocument(); + }); +}); diff --git a/tests/jest/components/ClassificationsForm.test.tsx b/tests/jest/components/ClassificationsForm.test.tsx new file mode 100644 index 0000000000..bd702a712b --- /dev/null +++ b/tests/jest/components/ClassificationsForm.test.tsx @@ -0,0 +1,458 @@ +import * as React from "react"; +import { render, screen, within, fireEvent } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import ClassificationsForm from "../../../src/components/ClassificationsForm"; +import { BookData } from "../../../src/interfaces"; +import genreData from "../../../src/components/__tests__/genreData"; + +// ClassificationsForm is a plain (unconnected) class component, so a bare +// `render` is enough — no store/context/router needed. + +const noValuesBook = (): BookData => ({ + id: "1", + title: "title", + audience: undefined, + targetAgeRange: ["12", "16"], + fiction: undefined, + categories: ["Space Opera"], +}); + +const fullBook = (): BookData => ({ + id: "1", + title: "title", + audience: "Young Adult", + targetAgeRange: ["12", "16"], + fiction: true, + categories: ["Space Opera"], +}); + +type RenderProps = Partial>; + +const renderForm = (book: BookData, props: RenderProps = {}) => { + const editClassifications = jest.fn(); + const result = render( + + ); + return { ...result, editClassifications }; +}; + +const audienceSelect = (container: HTMLElement) => + container.querySelector("select[name='audience']"); +const fictionRadio = (container: HTMLElement, value: string) => + container.querySelector( + `input[name='fiction'][value='${value}']` + ); +const removeButtons = (container: HTMLElement) => + Array.from(container.querySelectorAll(".with-remove-button")); + +// Selects a top-level genre in the GenreForm and clicks "Add". +const addGenre = async (container: HTMLElement, genre: string) => { + const user = userEvent.setup(); + const option = container.querySelector( + `select[name='genre'] option[value='${genre}']` + ); + fireEvent.click(option); + await user.click(screen.getByRole("button", { name: "Add" })); +}; + +// Selects a genre + subgenre in the GenreForm and clicks "Add". +const addSubgenre = async ( + container: HTMLElement, + genre: string, + subgenre: string +) => { + const user = userEvent.setup(); + fireEvent.click( + container.querySelector( + `select[name='genre'] option[value='${genre}']` + ) + ); + fireEvent.click( + container.querySelector( + `select[name='subgenre'] option[value='${subgenre}']` + ) + ); + await user.click(screen.getByRole("button", { name: "Add" })); +}; + +describe("ClassificationsForm", () => { + let confirmSpy: jest.SpyInstance; + let alertSpy: jest.SpyInstance; + + beforeEach(() => { + confirmSpy = jest.spyOn(window, "confirm").mockReturnValue(true); + alertSpy = jest.spyOn(window, "alert").mockImplementation(() => undefined); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe("rendering without classification values", () => { + it("shows no audience or fiction classification selected", () => { + const { container } = renderForm(noValuesBook()); + + const audience = audienceSelect(container); + expect(screen.getByText("Audience")).toBeInTheDocument(); + expect(audience).toHaveValue("None"); + // The "None" option is only present when no audience is selected. + expect(within(audience).getAllByRole("option")).toHaveLength(7); + + const none = fictionRadio(container, "none"); + const fiction = fictionRadio(container, "fiction"); + const nonfiction = fictionRadio(container, "nonfiction"); + + expect(none).toHaveAttribute("type", "radio"); + expect(none).toBeChecked(); + expect(fiction).not.toBeChecked(); + expect(nonfiction).not.toBeChecked(); + + const group = container.querySelector(".fiction-radio-input"); + expect(group).toHaveTextContent("None"); + expect(group).toHaveTextContent("Fiction"); + expect(group).toHaveTextContent("Nonfiction"); + }); + + it("does not submit without an audience or fiction classification", async () => { + const user = userEvent.setup(); + const { container, editClassifications } = renderForm(noValuesBook()); + + await user.click(screen.getByRole("button", { name: "Save" })); + + expect(editClassifications).not.toHaveBeenCalled(); + expect(audienceSelect(container)).toHaveValue("None"); + }); + + it("renders error messages without an audience or fiction classification", async () => { + const user = userEvent.setup(); + const { container, editClassifications } = renderForm(noValuesBook()); + + await user.click(screen.getByRole("button", { name: "Save" })); + + const alert = container.querySelector(".alert-danger"); + expect(alert).toBeInTheDocument(); + expect(alert).toHaveTextContent("No Audience classification selected."); + expect(alert).toHaveTextContent("No Fiction classification selected."); + expect(editClassifications).not.toHaveBeenCalled(); + }); + + it("moves focus to the error message so it is announced", () => { + jest.useFakeTimers(); + // Watch the focus() call itself: the component focuses the alert on a + // 500ms timer. Use fireEvent (not userEvent, which focuses the target it + // clicks) so the only focus() we observe is the intended one. + const focusSpy = jest.spyOn(HTMLElement.prototype, "focus"); + try { + const { container } = renderForm(noValuesBook()); + + fireEvent.click(screen.getByRole("button", { name: "Save" })); + expect(focusSpy).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(500); + + const alert = container.querySelector(".alert-danger"); + expect(focusSpy).toHaveBeenCalledTimes(1); + expect(focusSpy.mock.instances[0]).toBe(alert); + } finally { + focusSpy.mockRestore(); + jest.useRealTimers(); + } + }); + + it("does not submit if you didn't select an audience", async () => { + const user = userEvent.setup(); + const { container, editClassifications } = renderForm(noValuesBook()); + + // Choose a fiction classification but leave the audience unset. + await user.click(fictionRadio(container, "fiction")); + await user.click(screen.getByRole("button", { name: "Save" })); + + expect(editClassifications).not.toHaveBeenCalled(); + expect(container.querySelector(".alert-danger")).toBeInTheDocument(); + + // Now choose an audience and the form submits, clearing the error. + await user.selectOptions(audienceSelect(container), "Adult"); + await user.click(screen.getByRole("button", { name: "Save" })); + + expect(editClassifications).toHaveBeenCalledTimes(1); + expect(container.querySelector(".alert-danger")).not.toBeInTheDocument(); + }); + + it("does not submit if you didn't select a fiction classification", async () => { + const user = userEvent.setup(); + const { container, editClassifications } = renderForm(noValuesBook()); + + // Choose an audience but leave the fiction classification unset. + await user.selectOptions(audienceSelect(container), "Adult"); + await user.click(screen.getByRole("button", { name: "Save" })); + + expect(editClassifications).not.toHaveBeenCalled(); + expect(fictionRadio(container, "nonfiction")).not.toBeChecked(); + + // Now choose a fiction classification and the form submits. + await user.click(fictionRadio(container, "nonfiction")); + await user.click(screen.getByRole("button", { name: "Save" })); + + expect(editClassifications).toHaveBeenCalledTimes(1); + }); + }); + + describe("rendering", () => { + it("shows an editable select with audience options", () => { + const { container } = renderForm(fullBook()); + + const audience = audienceSelect(container); + expect(screen.getByText("Audience")).toBeInTheDocument(); + expect(audience).toHaveValue("Young Adult"); + // The "None" option is not rendered once an audience is selected. + expect(within(audience).getAllByRole("option")).toHaveLength(6); + }); + + it("shows editable inputs with min and max target age", () => { + const { container } = renderForm(fullBook()); + + expect( + container.querySelector("input[name='target_age_min']") + ).toHaveValue("12"); + expect( + container.querySelector("input[name='target_age_max']") + ).toHaveValue("16"); + }); + + it("shows editable radio buttons with fiction status", () => { + const { container } = renderForm(fullBook()); + + // With a fiction value set, the "None" fiction radio is not rendered. + expect(fictionRadio(container, "none")).toBeNull(); + expect(fictionRadio(container, "fiction")).toBeChecked(); + expect(fictionRadio(container, "nonfiction")).not.toBeChecked(); + + const group = container.querySelector(".fiction-radio-input"); + expect(group).toHaveTextContent("Fiction"); + expect(group).toHaveTextContent("Nonfiction"); + }); + + it("shows the book's full genres with remove buttons", () => { + const { container } = renderForm(fullBook()); + + const buttons = removeButtons(container); + expect(buttons).toHaveLength(1); + expect(buttons[0]).toHaveTextContent("Science Fiction > Space Opera"); + expect( + within(buttons[0]).getByRole("button", { name: /Delete/ }) + ).toBeInTheDocument(); + }); + + it("shows the book's full genres even if inconsistent with fiction status", () => { + const editClassifications = jest.fn(); + const { container, rerender } = render( + + ); + + rerender( + + ); + + const buttons = removeButtons(container); + expect(buttons).toHaveLength(1); + expect(buttons[0]).toHaveTextContent("Science Fiction > Space Opera"); + }); + + it("shows a genre form", () => { + const { container } = renderForm(fullBook()); + + const genreSelect = container.querySelector("select[name='genre']"); + expect(genreSelect).toBeInTheDocument(); + expect(genreSelect).not.toBeDisabled(); + expect(screen.getByText("Add Genre")).toBeInTheDocument(); + }); + + it("shows a submit button", () => { + renderForm(fullBook()); + expect(screen.getByRole("button", { name: "Save" })).toBeInTheDocument(); + }); + }); + + describe("behavior", () => { + it("shows and hides target age inputs when audience changes", async () => { + const user = userEvent.setup(); + const { container } = renderForm(fullBook()); + + expect( + container.querySelector("input[name='target_age_min']") + ).toBeInTheDocument(); + expect( + container.querySelector("input[name='target_age_max']") + ).toBeInTheDocument(); + + await user.selectOptions(audienceSelect(container), "Adult"); + expect( + container.querySelector("input[name='target_age_min']") + ).not.toBeInTheDocument(); + expect( + container.querySelector("input[name='target_age_max']") + ).not.toBeInTheDocument(); + + await user.selectOptions(audienceSelect(container), "Children"); + expect( + container.querySelector("input[name='target_age_min']") + ).toBeInTheDocument(); + expect( + container.querySelector("input[name='target_age_max']") + ).toBeInTheDocument(); + }); + + it("changes both fiction status radio buttons", async () => { + const user = userEvent.setup(); + const { container } = renderForm(fullBook()); + + expect(fictionRadio(container, "fiction")).toBeChecked(); + expect(fictionRadio(container, "nonfiction")).not.toBeChecked(); + + // Switching to nonfiction prompts for confirmation and clears the + // (fiction) genre. + await user.click(fictionRadio(container, "nonfiction")); + expect(confirmSpy).toHaveBeenCalled(); + expect(removeButtons(container)).toHaveLength(0); + expect(fictionRadio(container, "fiction")).not.toBeChecked(); + expect(fictionRadio(container, "nonfiction")).toBeChecked(); + + await user.click(fictionRadio(container, "fiction")); + expect(fictionRadio(container, "fiction")).toBeChecked(); + expect(fictionRadio(container, "nonfiction")).not.toBeChecked(); + }); + + it("adds a genre only after validating it against the audience", async () => { + const { container } = renderForm(fullBook()); + + expect(removeButtons(container)).toHaveLength(1); + + // Erotica is not allowed for a Young Adult audience: it is rejected with + // an alert and not added. + await addGenre(container, "Erotica"); + expect(alertSpy).toHaveBeenCalled(); + let buttons = removeButtons(container); + expect(buttons).toHaveLength(1); + expect(buttons[0]).not.toHaveTextContent("Erotica"); + + // Folklore is allowed and gets added. + await addGenre(container, "Folklore"); + buttons = removeButtons(container); + expect(buttons).toHaveLength(2); + expect(buttons.some((b) => b.textContent.includes("Folklore"))).toBe( + true + ); + }); + + it("removes a genre when its remove button is clicked", async () => { + const user = userEvent.setup(); + const { container } = renderForm(fullBook()); + + const button = removeButtons(container)[0]; + await user.click(within(button).getByRole("button", { name: /Delete/ })); + + expect(removeButtons(container)).toHaveLength(0); + }); + + it("submits data when the submit button is clicked", async () => { + const user = userEvent.setup(); + const { editClassifications } = renderForm(fullBook()); + + await user.click(screen.getByRole("button", { name: "Save" })); + + expect(editClassifications).toHaveBeenCalledTimes(1); + const data = editClassifications.mock.calls[0][0] as FormData; + expect(data.get("audience")).toBe("Young Adult"); + expect(data.get("target_age_min")).toBe("12"); + expect(data.get("target_age_max")).toBe("16"); + expect(data.get("fiction")).toBe("fiction"); + expect(data.getAll("genres")).toEqual(["Space Opera"]); + }); + + it("updates state upon receiving new state-related props", () => { + const editClassifications = jest.fn(); + const { container, rerender } = render( + + ); + + rerender( + + ); + + expect(audienceSelect(container)).toHaveValue("Adult"); + expect(fictionRadio(container, "nonfiction")).toBeChecked(); + expect(fictionRadio(container, "fiction")).not.toBeChecked(); + + const buttons = removeButtons(container); + expect(buttons).toHaveLength(1); + expect(buttons[0]).toHaveTextContent("Food & Health > Cooking"); + }); + + it("doesn't reset user edits upon receiving new state-unrelated props", async () => { + const user = userEvent.setup(); + const editClassifications = jest.fn(); + const { container, rerender } = render( + + ); + + // The user switches to nonfiction (clearing the fiction genre) and adds a + // nonfiction genre. + await user.click(fictionRadio(container, "nonfiction")); + expect(fictionRadio(container, "nonfiction")).toBeChecked(); + expect(removeButtons(container)).toHaveLength(0); + + await addSubgenre(container, "Food & Health", "Cooking"); + expect(removeButtons(container)).toHaveLength(1); + expect(container.querySelector(".with-remove-button")).toHaveTextContent( + "Food & Health > Cooking" + ); + + // A re-render caused by an unrelated prop change (disabling the form on + // submit) must not discard those edits. + rerender( + + ); + + expect(fictionRadio(container, "nonfiction")).toBeChecked(); + expect(container.querySelector(".with-remove-button")).toHaveTextContent( + "Food & Health > Cooking" + ); + }); + }); +}); diff --git a/tests/jest/components/Contributors.test.tsx b/tests/jest/components/Contributors.test.tsx new file mode 100644 index 0000000000..23f7f6202f --- /dev/null +++ b/tests/jest/components/Contributors.test.tsx @@ -0,0 +1,157 @@ +import * as React from "react"; +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import Contributors from "../../../src/components/Contributors"; +import { RolesData, ContributorData } from "../../../src/interfaces"; + +const roles: RolesData = { + aut: "Author", + ill: "Illustrator", + nrt: "Narrator", + trl: "Translator", +}; + +const contributors: ContributorData[] = [ + { name: "A Translator", role: "trl" }, + { name: "A Narrator", role: "nrt" }, +]; + +const renderContributors = ( + props: Partial> = {} +) => + render( + + ); + +const existingRows = (container: HTMLElement) => + Array.from(container.querySelectorAll(".with-remove-button")); + +const newContributorForm = () => + screen + .getByRole("button", { name: "Add" }) + .closest(".contributor-form") as HTMLElement; + +const roleSelect = (row: HTMLElement) => + row.querySelector('select[name="contributor-role"]') as HTMLSelectElement; +const nameInput = (row: HTMLElement) => + row.querySelector('input[name="contributor-name"]') as HTMLInputElement; + +describe("Contributors", () => { + it("displays a label", () => { + renderContributors(); + expect(screen.getByText("Authors and Contributors")).toBeInTheDocument(); + }); + + it("displays a list of existing contributors", () => { + const { container } = renderContributors(); + const rows = existingRows(container); + expect(rows).toHaveLength(2); + + expect(roleSelect(rows[0])).toHaveValue("Translator"); + expect(nameInput(rows[0])).toHaveValue("A Translator"); + expect(roleSelect(rows[1])).toHaveValue("Narrator"); + expect(nameInput(rows[1])).toHaveValue("A Narrator"); + + // Each role menu offers the full set of roles. + const options = within(roleSelect(rows[0])) + .getAllByRole("option") + .map((o) => (o as HTMLOptionElement).value); + expect(options).toStrictEqual(Object.values(roles)); + }); + + it("displays a blank field for adding a new contributor", () => { + renderContributors(); + const form = newContributorForm(); + expect(roleSelect(form)).toHaveValue("Author"); + expect(nameInput(form)).toHaveValue(""); + + const button = within(form).getByRole("button", { name: "Add" }); + expect(button).toHaveClass("add-contributor"); + }); + + it("deletes a contributor", async () => { + const user = userEvent.setup(); + const { container } = renderContributors(); + + let rows = existingRows(container); + expect(rows).toHaveLength(2); + + // Remove the first (Translator) row. + await user.click(within(rows[0]).getByRole("button")); + + rows = existingRows(container); + expect(rows).toHaveLength(1); + expect(roleSelect(rows[0])).toHaveValue("Narrator"); + expect(nameInput(rows[0])).toHaveValue("A Narrator"); + }); + + it("adds a contributor", async () => { + const user = userEvent.setup(); + const { container } = renderContributors(); + expect(existingRows(container)).toHaveLength(2); + + const form = newContributorForm(); + await user.selectOptions(roleSelect(form), "Illustrator"); + await user.type(nameInput(form), "An Illustrator"); + await user.click(within(form).getByRole("button", { name: "Add" })); + + const rows = existingRows(container); + expect(rows).toHaveLength(3); + expect(roleSelect(rows[2])).toHaveValue("Illustrator"); + expect(nameInput(rows[2])).toHaveValue("An Illustrator"); + }); + + it("does not add an empty string as a contributor's name", async () => { + const user = userEvent.setup(); + const { container } = renderContributors(); + + // The Add button starts disabled, so clicking it does nothing. + expect(screen.getByRole("button", { name: "Add" })).toBeDisabled(); + await user.click(screen.getByRole("button", { name: "Add" })); + expect(existingRows(container)).toHaveLength(2); + + // Typing a name enables the button and lets us add. + await user.type(nameInput(newContributorForm()), "An Author"); + expect(screen.getByRole("button", { name: "Add" })).toBeEnabled(); + await user.click(screen.getByRole("button", { name: "Add" })); + expect(existingRows(container)).toHaveLength(3); + + // Clearing the name disables the button again. + await user.clear(nameInput(newContributorForm())); + expect(screen.getByRole("button", { name: "Add" })).toBeDisabled(); + await user.click(screen.getByRole("button", { name: "Add" })); + expect(existingRows(container)).toHaveLength(3); + }); + + it("shows the full name of a contributor's known role", () => { + // "trl" and "nrt" resolve to their full names in the role menus. + const { container } = renderContributors(); + const rows = existingRows(container); + expect(roleSelect(rows[0])).toHaveValue("Translator"); + expect(roleSelect(rows[1])).toHaveValue("Narrator"); + }); + + it("resets the form after adding a contributor", async () => { + const user = userEvent.setup(); + renderContributors(); + + const form = newContributorForm(); + await user.selectOptions(roleSelect(form), "Illustrator"); + await user.type(nameInput(form), "An Illustrator"); + await user.click(within(form).getByRole("button", { name: "Add" })); + + // The add form is blank again, defaulted to "Author", with a disabled button. + const resetForm = newContributorForm(); + expect(nameInput(resetForm)).toHaveValue(""); + expect(roleSelect(resetForm)).toHaveValue("Author"); + expect( + within(resetForm).getByRole("button", { name: "Add" }) + ).toBeDisabled(); + }); +}); diff --git a/tests/jest/components/EditorField.test.tsx b/tests/jest/components/EditorField.test.tsx new file mode 100644 index 0000000000..d3762a6d36 --- /dev/null +++ b/tests/jest/components/EditorField.test.tsx @@ -0,0 +1,95 @@ +import * as React from "react"; +import { render, fireEvent } from "@testing-library/react"; +import { RichUtils } from "draft-js"; + +import EditorField from "../../../src/components/EditorField"; + +describe("EditorField", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("renders the three inline-style buttons and toggles the matching style on mouse-down", () => { + // There is no DOM-observable effect of toggling an inline style over a + // collapsed (unfocused) selection, so we drive the real mouse-down interaction + // and observe the actual `RichUtils.toggleInlineStyle` call the button triggers. + const toggleSpy = jest.spyOn(RichUtils, "toggleInlineStyle"); + + const { container } = render( + + ); + + const buttons = Array.from(container.querySelectorAll("button")); + expect(buttons).toHaveLength(3); + const [bold, italic, underline] = buttons; + + expect(bold).toHaveTextContent("Bold"); + expect(bold.querySelector("b")).toBeInTheDocument(); + expect(italic).toHaveTextContent("Italic"); + expect(italic.querySelector("i")).toBeInTheDocument(); + expect(underline).toHaveTextContent("Underline"); + expect(underline.querySelector("u")).toBeInTheDocument(); + + expect(toggleSpy).not.toHaveBeenCalled(); + + fireEvent.mouseDown(bold); + expect(toggleSpy).toHaveBeenCalledTimes(1); + expect(toggleSpy.mock.calls[0][1]).toBe("BOLD"); + + fireEvent.mouseDown(italic); + expect(toggleSpy).toHaveBeenCalledTimes(2); + expect(toggleSpy.mock.calls[1][1]).toBe("ITALIC"); + + fireEvent.mouseDown(underline); + expect(toggleSpy).toHaveBeenCalledTimes(3); + expect(toggleSpy.mock.calls[2][1]).toBe("UNDERLINE"); + }); + + it("exposes the current content as HTML through getValue()", () => { + // `getValue()` is EditorField's real imperative API (BookEditForm reads it via a + // ref), so mirror that: render with a ref and call it. + const ref = React.createRef(); + render( + + ); + expect(ref.current?.getValue()).toBe("

This is a summary.

"); + }); + + it("falls back to the default content when the content is empty", () => { + const expected = "

Update the content for this field.

"; + + const emptyParagraphRef = React.createRef(); + render( + + ); + expect(emptyParagraphRef.current?.getValue()).toBe(expected); + + const emptyStringRef = React.createRef(); + render(); + expect(emptyStringRef.current?.getValue()).toBe(expected); + + const undefinedRef = React.createRef(); + render( + + ); + expect(undefinedRef.current?.getValue()).toBe(expected); + }); + + it("disables the toolbar buttons and the editor when disabled", () => { + const { container, rerender } = render( + + ); + + const buttons = () => Array.from(container.querySelectorAll("button")); + const editable = () => container.querySelector("[contenteditable]"); + + expect(buttons()).toHaveLength(3); + buttons().forEach((button) => expect(button).not.toBeDisabled()); + expect(editable()).toHaveAttribute("contenteditable", "true"); + + rerender(); + + buttons().forEach((button) => expect(button).toBeDisabled()); + expect(editable()).toHaveAttribute("contenteditable", "false"); + }); +}); diff --git a/tests/jest/components/GenreForm.test.tsx b/tests/jest/components/GenreForm.test.tsx new file mode 100644 index 0000000000..461b47ed48 --- /dev/null +++ b/tests/jest/components/GenreForm.test.tsx @@ -0,0 +1,152 @@ +import * as React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import GenreForm from "../../../src/components/GenreForm"; +import genreData from "../../../src/components/__tests__/genreData"; + +const genreOptions = Object.keys(genreData["Fiction"]).map( + (name) => genreData["Fiction"][name] +); +const nonfictionOptions = Object.keys(genreData["Nonfiction"]).map( + (name) => genreData["Nonfiction"][name] +); +const bookGenres = ["Adventure", "Epic Fantasy"]; + +const renderForm = (props: Record = {}) => { + const addGenre = jest.fn(); + const result = render( + + ); + return { ...result, addGenre }; +}; + +const genreSelect = (container: HTMLElement) => + container.querySelector("select[name='genre']") as HTMLSelectElement; +const genreOptionEls = (container: HTMLElement) => + Array.from( + genreSelect(container).querySelectorAll("option") + ); +const subgenreSelect = (container: HTMLElement) => + container.querySelector( + "select[name='subgenre']" + ) as HTMLSelectElement | null; + +describe("GenreForm", () => { + describe("rendering", () => { + it("shows multiple select with top-level genres", () => { + const { container } = renderForm(); + const topLevelGenres = genreOptions.filter( + (genre) => genre.parents.length === 0 + ); + const options = genreOptionEls(container); + + expect(options).toHaveLength(topLevelGenres.length); + options.forEach((option, i) => { + const name = topLevelGenres[i].name; + const hasGenre = bookGenres.indexOf(name) !== -1; + const hasSubgenre = topLevelGenres[i].subgenres.length > 0; + const displayName = name + (hasSubgenre ? " >" : ""); + expect(option.value).toBe(name); + expect(option.disabled).toBe(hasGenre); + expect(option.textContent).toBe(displayName); + }); + }); + }); + + describe("behavior", () => { + it("shows multiple select with subgenres when genre is selected", () => { + const { container, rerender } = renderForm(); + + // Nothing is selected yet: no subgenre select and no Add button. + expect(subgenreSelect(container)).toBeNull(); + expect(screen.queryByRole("button", { name: "Add" })).toBeNull(); + + // Select a genre with no subgenres (the first option). The component wires + // selection to `onClick` on each