Skip to content

Commit

Permalink
Merge pull request #48 from art-institute-of-chicago/feature/paginate…
Browse files Browse the repository at this point in the history
…-search-results

My Museum Tour search pagination
  • Loading branch information
zachgarwood authored Sep 26, 2024
2 parents cb9560f + 55b9641 commit be110ee
Show file tree
Hide file tree
Showing 12 changed files with 476 additions and 75 deletions.
2 changes: 1 addition & 1 deletion src/MyMuseumTourBuilder.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ const MyMuseumTourBuilder = (props) => {
<div className="aic-ct__core">
<SearchBar {...SearchProps} />
<Themes {...SearchProps} />
<SearchResults />
<SearchResults {...SearchProps} />
</div>
</SearchProvider>
</NavPage>
Expand Down
71 changes: 71 additions & 0 deletions src/components/search/PageNumber.cy.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import React from "react";
import SearchResults from "./SearchResults";
import Pagination from "./Pagination";
import item from "../../../cypress/fixtures/json/item.json";
import { AppProvider } from "../../contexts/AppContext";
import { SearchProvider } from "../../contexts/SearchContext";

const pagination = { current_page: 1, total_pages: 2 };

describe("<PageNumber />", () => {
it("Renders", () => {
cy.mount(
<AppProvider>
<SearchProvider
searchError={null}
searchFetching={false}
searchResultItems={[item]}
pagination={pagination}
>
<SearchResults />
</SearchProvider>
</AppProvider>,
);
cy.get(".m-paginator__page").should("have.length", pagination.total_pages);
});

it("Highlights the current page", () => {
cy.mount(
<AppProvider>
<SearchProvider
searchError={null}
searchFetching={false}
searchResultItems={[item]}
pagination={pagination}
>
<SearchResults />
</SearchProvider>
</AppProvider>,
);
cy.get(".s-active .m-paginator__page").should(
"have.text",
pagination.current_page,
);
});

it("Updates the page when another page is clicked", () => {
const goToPage = cy.stub().as("goToPage");
cy.mount(
<AppProvider>
<SearchProvider
searchError={null}
searchFetching={false}
searchResultItems={[item]}
pagination={pagination}
>
<Pagination goToPage={goToPage} />
</SearchProvider>
</AppProvider>,
);
const pageOne = cy.get(".m-paginator__page").first();
const pageTwo = cy.get(".m-paginator__page").last();
pageOne.click();
cy.get("@goToPage").should("not.be.called");
pageTwo
.click()
.invoke("text")
.then((page_number) => {
cy.get("@goToPage").should("be.calledOnceWith", parseInt(page_number));
});
});
});
26 changes: 26 additions & 0 deletions src/components/search/PageNumber.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import React from "react";
import PropTypes from "prop-types";

function PageNumber({ page, is_current_page, goToPage }) {
const handleClick = () => {
if (!is_current_page) {
goToPage(page);
}
};

return (
<li className={is_current_page ? "s-active" : ""}>
<a className="m-paginator__page f-buttons" onClick={handleClick}>
{page}
</a>
</li>
);
}

PageNumber.propTypes = {
page: PropTypes.number,
is_current_page: PropTypes.bool,
goToPage: PropTypes.func,
};

export default PageNumber;
79 changes: 79 additions & 0 deletions src/components/search/Pagination.cy.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import React from "react";
import SearchResults from "./SearchResults";
import item from "../../../cypress/fixtures/json/item.json";
import { AppProvider } from "../../contexts/AppContext";
import { SearchProvider } from "../../contexts/SearchContext";

const pagination = { current_page: 1, total_pages: 2 };

describe("<Pagination />", () => {
it("Renders", () => {
cy.mount(
<AppProvider>
<SearchProvider
searchError={null}
searchFetching={false}
searchResultItems={[item]}
pagination={pagination}
>
<SearchResults />
</SearchProvider>
</AppProvider>,
);
cy.get(".m-paginator").should("exist");
});

it("Does not render with less than 2 pages", () => {
cy.mount(
<AppProvider>
<SearchProvider
searchError={null}
searchFetching={false}
searchResultItems={[item]}
pagination={{ current_page: 1, total_pages: 1 }}
>
<SearchResults />
</SearchProvider>
</AppProvider>,
);
cy.get(".m-paginator").should("not.exist");
});

it("Displays Previous and Next buttons", () => {
cy.mount(
<AppProvider>
<SearchProvider
searchError={null}
searchFetching={false}
searchResultItems={[item]}
pagination={pagination}
>
<SearchResults />
</SearchProvider>
</AppProvider>,
);
cy.get(".m-paginator__prev-next").should("exist");
cy.get(".m-paginator__prev-next li").should("have.length", 2);
cy.get(".m-paginator__prev").should("have.text", "Previous");
cy.get(".m-paginator__next").should("have.text", "Next");
});

it("Displays page buttons", () => {
cy.mount(
<AppProvider>
<SearchProvider
searchError={null}
searchFetching={false}
searchResultItems={[item]}
pagination={pagination}
>
<SearchResults />
</SearchProvider>
</AppProvider>,
);
cy.get(".m-paginator__pages li").should(
"have.length",
pagination.total_pages,
);
});
});
197 changes: 197 additions & 0 deletions src/components/search/Pagination.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import React, { useContext } from "react";
import PropTypes from "prop-types";
import { SearchContext } from "../../contexts/SearchContext";
import PageNumber from "./PageNumber";
import { range } from "./../../utils";

/**
* Much of the code and several of the comments below have been adapted from the
* Illuminate/Pagination library in the Laravel framework.
*/
function Pagination({ goToPage }) {
const onEachSide = 3;
const perPage = 60;

const { pagination } = useContext(SearchContext);

const handleNextClick = () => {
if (hasMorePages()) {
goToPage(pagination.current_page + 1);
}
};

const handlePreviousClick = () => {
if (!onFirstPage()) {
goToPage(pagination.current_page - 1);
}
};

const hasPages = () => {
return pagination?.total_pages > 1;
};

const hasMorePages = () => {
return pagination.total_pages > pagination.current_page;
};

const onFirstPage = () => {
return pagination.current_page <= 1;
};

const smallSlider = () => {
return {
first: range(1, pagination.total_pages),
slider: null,
last: null,
};
};

const slider = () => {
let window = onEachSide + 4; // Is this 4 related to the 8 above?
if (!hasPages()) {
return { first: null, slider: null, last: null };
}
// If the current page is very close to the beginning of the page range, we will
// just render the beginning of the page range, followed by the last 2 of the
// links in this list, since we will not have room to create a full slider.
if (pagination.current_page <= window) {
return sliderTooCloseToBeginning(window);
}
// If the current page is close to the ending of the page range we will just get
// this first couple pages, followed by a larger window of these ending pages
// since we're too close to the end of the list to create a full on slider.
else if (pagination.current_page > pagination.total_pages - window) {
return sliderTooCloseToEnding(window);
}
// If we have enough room on both sides of the current page to build a slider we
// will surround it with both the beginning and ending caps, with this window
// of pages in the middle providing a Google style sliding paginator setup.
return fullSlider();
};

const sliderTooCloseToBeginning = (window) => {
let too_close_to_beginning = window + onEachSide;
return {
first: range(1, too_close_to_beginning),
slider: null,
last: getLast(),
};
};

const sliderTooCloseToEnding = (window) => {
let too_close_to_ending = window + (onEachSide - 1);
return {
first: getFirst(),
slider: null,
last: range(
pagination.total_pages - too_close_to_ending,
pagination.total_pages,
),
};
};

const fullSlider = () => {
return {
first: getFirst(),
slider: getAdjacent(),
last: getLast(),
};
};

/**
* Get the page range for the current page window.
*
* @return {number[]}
*/
const getAdjacent = () => {
return range(
pagination.current_page - onEachSide,
pagination.current_page + onEachSide,
);
};

/**
* Get the range of first pages of the slider
*
* @return {number[]}
*/
const getFirst = () => {
return range(1, 2);
};

/**
* Get the range of last pages of the slider
*
* @return {number[]}
*/
const getLast = () => {
return range(pagination.total_pages - 1, pagination.total_pages);
};

// I'm not sure what the 8 is meant to represent
let window =
pagination?.total_pages < onEachSide * 2 + 8 ? smallSlider() : slider();
let elements = [
window.first,
Array.isArray(window.slider) ? ["..."] : null,
window.slider,
Array.isArray(window.last) ? ["..."] : null,
window.last,
].filter((element) => element); // Filter out nulls

return (
<>
{hasPages() && (
<nav className="m-paginator">
<ul className="m-paginator__prev-next">
<li>
<a
className="m-paginator__next f-buttons"
onClick={handleNextClick}
>
Next
</a>
</li>
<li>
<a
className="m-paginator__prev f-buttons"
onClick={handlePreviousClick}
>
Previous
</a>
</li>
</ul>
<ul className="m-paginator__pages">
{elements.map((element) =>
element.map((page, index) => (
<React.Fragment key={index}>
{typeof page === "number" && page * perPage <= 10_000 && (
<PageNumber
page={page}
is_current_page={page === pagination.current_page}
goToPage={goToPage}
/>
)}
{typeof page === "string" && ( // Ellipses
<li>
<span className="f-buttons">&hellip;</span>
</li>
)}
</React.Fragment>
)),
)}
</ul>
<p className="m-paginator__current-page">
Page {pagination.current_page}
</p>
</nav>
)}
</>
);
}

Pagination.propTypes = {
goToPage: PropTypes.func,
};

export default Pagination;
Loading

0 comments on commit be110ee

Please sign in to comment.