AirNYT: Pagination + Filtering + Responsiveness

Follow us on LinkedIn for our latest data and tips!

, ,

AirNYT: Pagination + Filtering + Responsiveness

This tutorial picks up where we left off around building the map and markers. This is part of a larger tutorial series on cloning the Airbnb map and cards interface to be able to explore the last 7 years of New York Times travel recommendations. The short name for this app/series is AirNYT.
In this part of the tutorial, we will continue building out the following:

  • Working Pagination to make exploring the data easier/faster
  • A popup year filter that allows users to show NYT data by year
  • Text/Search Filtering to allow for string query filtering of the data
  • A responsive menu
  • General responsible responsiveness to make this app mobile-friendly

Our end goal is this: http://airnyt.surge.sh

Building working pagination to make exploring the data easier

If you play around with the Airbnb interface as much as I have, you’ll notice that they use infinite scroll when the map is hidden and pagination when the map is showing. This makes perfect sense, from the user’s perspective, as the map should only display a subset of the total properties. Well, you might be able to make some sort of cool infinite scroll updating map, but I don’t think most user would find this intuitive. I mention this background so as to say: we need pagination.
If Github stars are a proxy for popularity, then most React developers are using react-paginate for their pagination. Well, I tried and tried to like and use react-paginate but I found it lacking on the docs and props front. It felt sort of heavy and clunky and Angular 1-ey to me. After some Googling, I came across react-paginating. I found it’s API and examples much easier to understand so I went with this.
To achieve basic client-side pagination, we need two things:

  1. A pagination ui with next buttons and page numbers
  2. To appropriately slice our big array down to only display a subset of it on the appropriate page

This was achieved fairly easily with the following code:
I first needed to calculate a variety of values within my LocationsGrid component. I’ll comment what each of them do:

const { currentPage } = this.state; // a state variable that tracks which page the user is on.
const resultsPerPage = 30; // how many results I’ll display
const pageCount = Math.ceil(locations.length / resultsPerPage); // quantity of pages
const total = Math.ceil(locations.length); // total number of values
const offset = (currentPage - 1) * resultsPerPage; // the specific area of my area I need to slice out for that page
const locationsSlicedDownOnPage = locations.slice(
  offset,
  offset + resultsPerPage
); // a subset of my locations array to be displayed on the page

The following simple function changes the page:

handlePageChange = page => {
  this.setState({
    currentPage: page
  });
};

As you can see, with very little code, we can calculate the appropriate props to give to our PaginationComponent:

// LocationsGrid.js
<PaginationComponent
  total={total}
  resultsPerPage={resultsPerPage}
  pageCount={pageCount}
  currentPage={currentPage}
  handlePageChange={this.handlePageChange}
  offset={offset}
/>;

The pagination component has a bit more going on, but it’s easy to figure out with some explaining. First, take a look a what we’re making:

Here’s the code:

const PaginationComponent = ({
  total,
  resultsPerPage,
  pageCount,
  currentPage,
  handlePageChange,
  offset
}) => {
  return (
    <Pagination
      total={total}
      limit={resultsPerPage}
      pageCount={pageCount}
      currentPage={currentPage}
      offset={Math.ceil(offset)}
      resultsPerPage={Math.ceil(resultsPerPage)}
    >
      {({
        pages,
        currentPage,
        hasNextPage,
        hasPreviousPage,
        previousPage,
        nextPage,
        totalPages,
        getPageItemProps
      }) => (
        <div
          style={{
            display: "flex",
            flexDirection: "column",
            justifyContent: "space-between",
            alignItems: "center"
          }}
        >
          <div
            style={{
              display: "flex",
              justifyContent: "space-evenly",
              alignItems: "center",
              width: "300px"
            }}
          >
            {hasPreviousPage && (
              <NavigateBefore
                style={{
                  border: "1px solid #008489 ",
                  borderRadius: "50%",
                  color: "#008489"
                }}
                {...getPageItemProps({
                  pageValue: previousPage,
                  onPageChange: handlePageChange
                })}
              >
                {"<"}
              </NavigateBefore>
            )}
            {pages.map(page => {
              let activePage = { color: "#008489", cursor: "pointer" };
              if (currentPage === page) {
                activePage = {
                  border: "1px solid #008489 ",
                  borderRadius: "50%",
                  color: "#008489",
                  borderRadius: "50%",
                  width: "25px",
                  height: "25px",
                  display: "flex",
                  alignItems: "center",
                  justifyContent: "center",
                  backgroundColor: "#008489",
                  color: "white"
                };
              }
              if (
                page < currentPage + 3 &&
                page > currentPage - 3 &&
                page != totalPages
              ) {
                return (
                  <Typography
                    variant="subtitle2"
                    gutterBottom
                    key={page}
                    style={activePage}
                    {...getPageItemProps({
                      pageValue: page,
                      onPageChange: handlePageChange
                    })}
                  >
                    {page}
                  </Typography>
                );
              }
            })}
            <span>...</span>
            {pages.map(page => {
              let activePage = null;
              if (currentPage === page) {
                activePage = {
                  border: "1px solid #008489 ",
                  borderRadius: "50%",
                  color: "#008489",
                  cursor: "pointer"
                };
              }
              if (page > totalPages - 1) {
                return (
                  <Typography
                    variant="subtitle2"
                    gutterBottom
                    key={page}
                    style={activePage}
                    style={{
                      color: "#008489",
                      cursor: "pointer"
                    }}
                    {...getPageItemProps({
                      pageValue: page,
                      onPageChange: handlePageChange
                    })}
                  >
                    {page}
                  </Typography>
                );
              }
            })}
            {hasNextPage && (
              <NavigateNext
                {...getPageItemProps({
                  pageValue: nextPage,
                  onPageChange: handlePageChange
                })}
                style={{
                  border: "1px solid #008489 ",
                  borderRadius: "50%",
                  color: "#008489",
                  cursor: "pointer"
                }}
              >
                {">"}
              </NavigateNext>
            )}
          </div>

          <div>
            <Typography variant="caption" gutterBottom>
              {offset} - {offset + resultsPerPage} of {total} Locations
            </Typography>
          </div>
        </div>
      )}
    </Pagination>
  );
};

export default PaginationComponent;

I’ll break down what’s happening. Our pagination needs a previous and next arrows. Those are created using the material-ui icons (NavigateBefore and NavigateNext). We need a subset of pages to display, then an ellipsis …, followed by the last page. That’s what all of those pages.map are accomplishing.
The page < currentPage + 3 && page > currentPage – 3 && page != totalPages part is getting a very specific subset of the pages to display to user. In English, this says “show them a few behind the current page, a few ahead of the current page, but not the last page!”.
The section at the bottom of the code is telling the user how many of the listings they are seeing:


It’s a fair amount of code, but it ultimately adds up to a component that is pretty close the Airbnb version.
Airbnb’s:

AirNYT’s

Boo-yah!

A client-side year filter that allows users to show NYT data by year

I really like the way Airbnb gives users a ton of different filters without cluttering up their ui. Their filter bar features buttons that queue popovers/modals to achieve this.

I mimicked this using Material-ui’s Popover component. Here’s how I created a filter that allows user to filter out different years from the total results.

// FilterPopup.js
<div className={classes.FilterPopup}>
  <Button
    className={this.state.buttonActive ? classes.active : classes.inactive}
    // toggle the active class to change the color of the button
    onClick={this.handleClick}
    variant="outlined" // conveniently built-in button variant
  >
    Year
  </Button>
  <Popover
    id="simple-popper"
    open={open}
    anchorEl={anchorEl}
    onClose={this.handleClose}
    className={classes.popover}
    PaperProps={{ classes: { root: classes.rightPaper } }}
    BackdropProps={{
      classes: {
        root: classes.backDropStyling // this styling creates the background change to make the popover more pronounced
      }
    }}
  >
    <Card className={classes.card}>
      <CardContent className={classes.cardcontent}>
        <ChipFilter // popover contains the ChipFilter which is where the actual filtering will take place
          years={this.props.years}
          toggleChipProperty={this.props.toggleChipProperty}
          clearAllChips={this.props.clearAllChips}
          selectAllChips={this.props.selectAllChips}
        />
      </CardContent>
    </Card>
  </Popover>
</div>;

ChipFilter is a component that allows users to click the years to toggle them on and off (and thus achieve this client-side filtering):

// ChipFilter.js
<div className={classes.root}>
  <div className={classes.chipSection}>
    // map over the years array and style them according to whether they are
    toggled on or off
    {this.props.years.map((year, index) => {
      return (
        <span
          key={index}
          onClick={this.props.toggleChipProperty(year.key)}
          className={year.showing ? classes.active : classes.inactive}
        >
          {year.label}{" "}
        </span>
      );
    })}
  </div>
  // A simple way to select or deselect all
  <div className={classes.buttonSection}>
    <Button
      variant="outlined"
      color="ternary"
      onClick={this.props.clearAllChips}
      className={classes.button}
    >
      Deselect All
    </Button>
    <Button
      variant="outlined"
      color="ternary"
      onClick={this.props.selectAllChips}
      className={classes.button}
    >
      Select All
    </Button>
  </div>
</div>;

But how does the actual filtering itself happen? That takes place within our App.js:
State.years is an array of objects with key, label, and showing properties:

years: [
  {
    key: 0,
    label: 2011,
    showing: true
  },
  {
    key: 1,
    label: 2012,
    showing: true
  },
  {
    key: 2,
    label: 2013,
    showing: true
  }...
];

matchChipsAndTags is the filtering function that determines whether the location’s year matches up with the state.year’s object showing property. It does this with some ES6 kungfu:

const matchChipsAndTags = function(chips, year) {
  return chips.some(function(chip) {
    return year === chip.label && chip.showing === true;
  });
};

🤔
This function takes the state.chips and the location.year as its parameters. Array.prototype().some is a newer array prototype function that stops executing once a truthy value has been found. So, in English, this function is returning true or false depending on whether that specific locations year property matches the currently showing chips.
So, for instance, a Card/Location like Istanbul will only get rendered (aka appear) if its year property (2013) is matches the chip.label of 2013 (it will) and is that particular chip’s showing property (within state) is true.

Ultimately, the array is filtered down like so:

let filteredlocations = textfilteredlocations.filter(location =>
  matchChipsAndTags(this.state.years, location.year)
);

Text/Search Filtering to allow for string queries of the data

Airbnb has a search box in their header that allows for easy searching of places/experiences:

We’re not going to recreate the whole server-side search experience/function here but I think some client-side filtering would work nicely. This is very easy to do in React.
First, within state in App.js, I’ll declare the search/string value:

filterValue: ""

I’ll then declare a function in App.js to change this value:

handleSearch = e => {
  this.setState({ filterValue: e.target.value });
};

I’ll pass this function as a prop to my Header component. Within component, I’ll call the function within an InputBase component:

const { classes, handleSearch } = this.props;
<InputBase
  placeholder="Filter Places..."
  type="search"
  onChange={handleSearch}
  classes={{
    root: classes.inputRoot,
    input: classes.inputInput
  }}
/>;

I use InputBase instead of TextField so as to avoid any of the default TextField styling (it underlines the search box). TextField is a component composed of several other underlying component, including InputBase.

This search filter box isn’t the most interesting component, but I did find it useful for doing country searches. It was fun to see when different places in the same country got that sweet NYT mention. Here’s what Mexico/Australia look like:

As you see, with New Mexico popping up in a Mexico, it would take some work and data formatting/reorganizing (e.g. a country property in the dataset) to make this truly a country only search. I would also love to have a continent search, but that’s for another tutorial.

A responsive menu

Airbnb’s menu crunches down into a full-screen mobile dropdown on mobile. This frees up a lot of space and only shows the user what they need to see. Here’s how we can achieve the same thing with material-ui.
Material-ui has a really helpful CSS in JSS API for handling breakpoints. You put the breakpoints right into your styles like so:

[theme.breakpoints.up('lg')]: {
   backgroundColor: green[500],
},

This translates to: large size screens and up will have this background style.

[theme.breakpoints.down('sm')]: {
backgroundColor: theme.palette.secondary.main,
},

This would translate to: all the way down to the small size of screen will have this style.
You can set your breakpoints accordingly when you define your theme (usually in index.js or app.js) using createMuiTheme.
To achieve something like the airbnb mobile menu, I’ll first hide the normal menu items on small screens with this styling:

menuItems:{
[theme.breakpoints.down('sm')]: {
display:"none"
},

I’m going to create a dropdown menu, but first I’ll put the styling in for it:

dropdownMenu:{
[theme.breakpoints.up('md')]: { // for mid-size screens and up, please hide this !
display:"none"
},

I then create a DropDownMenu component like so:

class DropDownMenu extends React.Component {
  state = {
    anchorEl: null
  };
  handleClick = event => {
    this.setState({ anchorEl: event.currentTarget });
  };
  handleClose = () => {
    this.setState({ anchorEl: null });
  };
  render() {
    const { anchorEl } = this.state;
    const { classes } = this.props;
    const open = Boolean(anchorEl);
    return (
      <div className={classes.DropDownMenuRoot}>
        <IconButton
          aria-label="More"
          aria-owns={open ? "long-menu" : undefined}
          aria-haspopup="true"
          onClick={this.handleClick}
          className={classes.downCarrotButton}
        >
          <KeyboardArrowDown />
        </IconButton>
        <Menu
          id="long-menu"
          anchorEl={anchorEl}
          open={open}
          onClose={this.handleClose}
          PaperProps={{
            style: {
              maxHeight: ITEM_HEIGHT * 4.5,
              width: 200
            }
          }}
        >
          <MenuItem className={classes.menubuttons} color="inherit">
            Help
          </MenuItem>
          <MenuItem className={classes.menubuttons} color="inherit">
            Sign Up
          </MenuItem>
          <MenuItem className={classes.menubuttons} color="inherit">
            Log in
          </MenuItem>
        </Menu>
      </div>
    );
  }
}

This creates a little popover menu that is toggled using the down arrow next to the logo. It’s not the elegant full-screen menu that airbnb offers, but it’s definitely a good start.
To make this app more generally functional and appealing on mobile, I went through and made small tweaks in the styling using these same theme breakpoint helpers.

To do

This a fun little map + cards + filtering app. But there’s still work to be done. These are the items I’d like to cover in future tutorials:

  • Mobile Map Toggling

Right now, I’m hiding the map div on mobile. Lame! Airbnb has a nice way of toggling the mobile map vs. the cards/list using a little hovering button in the lower right corner of the screen. With a material-ui floating action button and some clever breakpoints and styling, we can make the map available on mobile.

  • Accounts and Favoriting

I’ll show how to incorporate firebase into this app to allow for user authentication and letting people save their favorite places.

  • Infinite Scroll on Cards View and Pagination on Map View

We made a really nice react-virtualized fast loading list in a previous tut. There’s no reason we can’t show this infinite loading list when the map isn’t visible and show the paginated list when the map is showing. We can get the best of both worlds this way.

  • Small ui tweaks

The list should scroll to the top when the page is changed. It could be helpful to center the map on the appropriate map marker when a user hovers on a card.

  • Pop Up Card on the Marker

Airbnb’s map markers show a little pop up card when you click on them. This gives you more info about the place and is quite useful. I’ll add these in a future tutorial.

Stay tuned for more tutorials and the coming end of this tutorial!