Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

My Museum Tour search pagination #48

Merged
merged 22 commits into from
Sep 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
e6dd6a2
Add `page` to API request params [WEB-2905]
zachgarwood Sep 19, 2024
44c60f0
Add pagination object and setter to SearchContext [WEB-2905]
zachgarwood Sep 19, 2024
ed0f4c4
Set pagination state from API response [WEB-2905]
zachgarwood Sep 19, 2024
ed09138
Update `useFetch` instantiations [WEB-2905]
zachgarwood Sep 19, 2024
55220e4
Create Pagination and PageNumber components [WEB-2905]
zachgarwood Sep 20, 2024
728710c
Lint new components [WEB-2905]
zachgarwood Sep 20, 2024
d753da2
Fix search results tests [WEB-2905]
zachgarwood Sep 20, 2024
a13a987
Create range helper function in utils [WEB-2905]
zachgarwood Sep 24, 2024
f12bdd4
Fix util function documentation [WEB-2905]
zachgarwood Sep 24, 2024
21fc873
Pass hideFromTours array into SearchResults [WEB-2905]
zachgarwood Sep 24, 2024
f364ff6
Create range of page numbers [WEB-2905]
zachgarwood Sep 24, 2024
346b87d
Create goToPage SearchResults function [WEB-2905]
zachgarwood Sep 24, 2024
580a408
Pass goToPage to Pagination/PageNumber [WEB-2905]
zachgarwood Sep 24, 2024
ce2c871
Fix range function [WEB-2905]
zachgarwood Sep 25, 2024
eff6508
Add f-buttons class to list links [WEB-2905]
zachgarwood Sep 25, 2024
0ce5e09
Include theme search params when retrieving pages [WEB-2905]
zachgarwood Sep 25, 2024
dc04496
Adapt pagination code from Illuminate/Pagination [WEB-2905]
zachgarwood Sep 25, 2024
9e94db8
Update SearchResults test with SearchProvider data [WEB-2905]
zachgarwood Sep 25, 2024
61d3763
Update useFetch hook to rely directly on SearchContext state [WEB-2905]
zachgarwood Sep 25, 2024
7293de6
Fix SearchResults test [WEB-2905]
zachgarwood Sep 25, 2024
d78f1a6
Fix default values for `useFetch` options [WEB-2905]
zachgarwood Sep 25, 2024
55b9641
Create tests fro Pagination and PagNumber components [WEB-2905]
zachgarwood Sep 26, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading