TinyHouse: A Fullstack React Masterclass with TypeScript and GraphQL - Part Two - PT 5

THE USERLISTINGS & USERBOOKINGS REACT COMPONENTS

:memo: Documentation on the <List /> component we use from Ant Design can be found - here.

In the last lesson, we’ve been able to query for a single user and display some of that queried information in the /user/:id page of that user. In this lesson, we’ll look to query the paginated list of listings and bookings for the user and display it on the user’s page.

  • The listings section will be a child <UserListings /> component of the user page.
  • The bookings section will be a child <UserBookings /> component of the user page.

<UserListings /> and <UserBookings /> resemble one another based on the cards being shown. The UI for these cards is going to be used in many different areas of our app including the /home page and the /listings/:location? page.

Since these listing card elements are going to be used in multiple areas of our application, we’ll create a <ListingCard /> component to represent a single listing element in our application’s src/lib/components/ folder that can be used anywhere. The <ListingCard /> component will be fairly straightforward - it will accept a series of props such as the price of a listing, the title , description , numOfGuests , and it will display that information within a card.

UPDATE USER QUERY

The first thing we’re going to do is update the user query document in our app to query for the bookings and listings fields of a user . bookings and listings are paginated fields that require us to pass in a limit and page arguments. We’re going to be passing these arguments from the <User /> component that makes the query so let’s state the User query document we’ve established in the src/lib/graphql/queries/User/index.ts file is going to accept a some new arguments.

We’ll state that the User query is going to accept a bookingsPage argument that will determine which booking page the user is in and will be an integer type. The User query will also accept a listingsPage argument that will determine which listings page the user is in. Since we’ll have the same limit for the number of bookings or listings that can be shown on a single page, we’ll only specify a single limit argument is to be passed into the User query.

import { gql } from "apollo-boost";

export const USER = gql`
  query User($id: ID!, $bookingsPage: Int!, $listingsPage: Int!, $limit: Int!) {
    #...
  }
`;

bookings

In our user query statement, we’ll now query for the bookings field and pass the limit argument along for the limit of bookings we want in a single page. We’ll also say the value for the page argument for the bookings field will be bookingsPage .

import { gql } from "apollo-boost";

export const USER = gql`
  query User($id: ID!, $bookingsPage: Int!, $listingsPage: Int!, $limit: Int!) {
    user(id: $id) {
      id
      name
      avatar
      contact
      hasWallet
      income
      bookings(limit: $limit, page: $bookingsPage) {}
    }
  }
`;

We’ll now query for the fields we’ll want from within the bookings field.

  • We’ll query for the total field to get the total number of bookings that are returned.
  • We’ll query for the result field which is the actual list of booking objects. In each booking object, we’ll query for:
    • The id of the booking.
    • The listing of the booking which we’ll further query for the id , title , image , address , price , and numOfGuests of the listing.
    • The checkIn and checkOut dates of the booking.
import { gql } from "apollo-boost";

export const USER = gql`
  query User($id: ID!, $bookingsPage: Int!, $listingsPage: Int!, $limit: Int!) {
    user(id: $id) {
      id
      name
      avatar
      contact
      hasWallet
      income
      bookings(limit: $limit, page: $bookingsPage) {
        total
        result {
          id
          listing {
            id
            title
            image
            address
            price
            numOfGuests
          }
          checkIn
          checkOut
        }
      }
    }
  }
`;

listings

The listings field we’ll query from the user object is going to be very similar to what we query for the bookings field except that there’s no checkIn and checkOut information. The result field of listings is the list of listing objects we’ll query where we’ll get the id , title , image , address , price , and numOfGuests of the listing. We’ll also ensure we provide the limit and listingsPage values for the limit and page arguments the listings field expects.

With all these changes, the USER query document we’ve established will look like:

client/src/lib/graphql/queries/User/index.ts

import { gql } from "apollo-boost";

export const USER = gql`
  query User($id: ID!, $bookingsPage: Int!, $listingsPage: Int!, $limit: Int!) {
    user(id: $id) {
      id
      name
      avatar
      contact
      hasWallet
      income
      bookings(limit: $limit, page: $bookingsPage) {
        total
        result {
          id
          listing {
            id
            title
            image
            address
            price
            numOfGuests
          }
          checkIn
          checkOut
        }
      }
      listings(limit: $limit, page: $listingsPage) {
        total
        result {
          id
          title
          image
          address
          price
          numOfGuests
        }
      }
    }
  }
`;

There shouldn’t be a reason for us to update the schema in our client application since the schema has remained the same from the last lesson but we’ll now update the autogenerated type definitions for our GraphQL API in our client.

We’ll head to the terminal and run the codegen:generate command in our client project.

npm run codegen:generate

<LISTINGCARD />

With our autogenerated type definitions updated, the query we’ve established in the <User /> component will currently throw an error since we’re not passing in the additional variables the query now accepts ( bookingsPage , listingsPage , and limit ). We’ll come back to this in a second. First, we’ll create the custom <ListingCard /> component that our upcoming <UserListings /> and <UserBookings /> components are going to use.

We’ll create the <ListingCard /> component in the src/lib/components/ folder.

client/
  // ...
  src/
    lib/
      components/
        // ...
        ListingCard/
          index.tsx
    // ...
  // ...

In the src/lib/components/index.ts file, we’ll re-export the <ListingCard /> component we’ll shortly create.

client/src/lib/components/index.ts

export * from "./ListingCard";

The <ListingCard /> component we’ll create will mostly be presentational and display some information about a single listing. In the src/lib/components/ListingCard/index.tsx file, we’ll first import the components we’ll need to use from Ant Design - the Card , Icon , and Typography components.

We’ll expect the <ListingCard /> component to accept a single listing object prop which will have an id , title, image , and address fields all of which are of type string . The listing object prop will also have a price and numOfGuests fields which are to be number values.

import React from "react";
import { Card, Icon, Typography } from "antd";

interface Props {
  listing: {
    id: string;
    title: string;
    image: string;
    address: string;
    price: number;
    numOfGuests: number;
  };
}

We’ll destruct the <Text /> and <Title /> components from the <Typography /> component. We’ll create and export the <ListingCard /> component function and in the component, we’ll destruct the field values we’ll want to access from the listing object prop.

import React from "react";
import { Card, Icon, Typography } from "antd";

interface Props {
  listing: {
    id: string;
    title: string;
    image: string;
    address: string;
    price: number;
    numOfGuests: number;
  };
}

export const ListingCard = ({ listing }: Props) => {
  const { title, image, address, price, numOfGuests } = listing;
};

In the <ListingCard /> component’s return statement, we’ll return the <Card /> component from Ant Design. In the <Card /> component cover prop, we’ll state the backgroundImage of the cover is to be the listing image. The rest of the contents within the <Card /> component will display information of the listing such as its price , title , address , and numOfGuests .

import React from "react";
import { Card, Icon, Typography } from "antd";

interface Props {
  listing: {
    id: string;
    title: string;
    image: string;
    address: string;
    price: number;
    numOfGuests: number;
  };
}

const { Text, Title } = Typography;

export const ListingCard = ({ listing }: Props) => {
  const { title, image, address, price, numOfGuests } = listing;

  return (
    <Card
      hoverable
      cover={
        <div
          style={{ backgroundImage: `url(${image})` }}
          className="listing-card__cover-img"
        />
      }
    >
      <div className="listing-card__details">
        <div className="listing-card__description">
          <Title level={4} className="listing-card__price">
            {price}
            <span>/day</span>
          </Title>
          <Text strong ellipsis className="listing-card__title">
            {title}
          </Text>
          <Text ellipsis className="listing-card__address">
            {address}
          </Text>
        </div>
        <div className="listing-card__dimensions listing-card__dimensions--guests">
          <Icon type="user" />
          <Text>{numOfGuests} guests</Text>
        </div>
      </div>
    </Card>
  );
};

This will pretty much be the entire <ListingCard /> component. We’ll make some minor changes to it when we survey and see how it looks in our client application.

Icon is a useful component from Ant Design that provides a large list of icons that can be accessed by simply providing a value for the icon’s type prop. In <ListingCard /> , we’ve used the <Icon /> component and provided a type value of user to get the user icon.

<USER />

We’ll head over to the <User /> component and first look to update the user query being made. The query for the user field now expects three new variables - bookingsPage , listingsPage , and limit . For the bookings and listings page values, we want our <User /> component to keep track of these values and update them based on which of the pages the user wants to visit. As a result, these values will be best kept as component state so we’ll import the useState Hook in our <User /> component file.

client/src/sections/User/index.tsx

import React, { useState } from "react";

At the top of our <Listings /> component, we’ll use the useState Hook to create two new state values - bookingsPage and listingsPage . We’ll initialize these page values with the value of 1 since when the user first visits the /user/:id page, we’ll want them to see the first page for both the bookings and listings lists.

We’ll also destruct functions that will be used to update these state values.

export const User = ({ viewer, match }: Props & RouteComponentProps<MatchParams>) => {
  const [listingsPage, setListingsPage] = useState(1);
  const [bookingsPage, setBookingsPage] = useState(1);

  // ...
};

Since the limit value (i.e. the limit of the number of bookings or listings that should show for a single page) will stay the same and we won’t want the user to update this, we’ll create a constant above our component called PAGE_LIMIT that’ll reference the limit of paginated items in a page - which will be 4 .

In our useQuery Hook declaration, we’ll then pass the values for the new variables - bookingsPage , listingsPage , and limit .

// ...

const PAGE_LIMIT = 4;

export const User = ({ viewer, match }: Props & RouteComponentProps<MatchParams>) => {
  const [listingsPage, setListingsPage] = useState(1);
  const [bookingsPage, setBookingsPage] = useState(1);

  const { data, loading, error } = useQuery<UserData, UserVariables>(USER, {
    variables: {
      id: match.params.id,
      bookingsPage,
      listingsPage,
      limit: PAGE_LIMIT
    }
  });

  // ...

  return (
    // ...
  );
};

We’ll now have the <User /> component render the child <UserBookings /> and <UserListings /> components before we create them. In the <User /> component, we’ll check for if the user data exists and if so - we’ll assign the listings and bookings fields of the user data to the constants userListings and userBookings respectively.

// ...

const PAGE_LIMIT = 4;

export const User = ({ viewer, match }: Props & RouteComponentProps<MatchParams>) => {
  const [listingsPage, setListingsPage] = useState(1);
  const [bookingsPage, setBookingsPage] = useState(1);

  const { data, loading, error } = useQuery<UserData, UserVariables>(USER, {
    variables: {
      id: match.params.id,
      bookingsPage,
      listingsPage,
      limit: PAGE_LIMIT
    }
  });

  // ...

  const userListings = user ? user.listings : null;
  const userBookings = user ? user.bookings : null;

  // ...

  return (
    // ...
  );
};

We’ll create constant elements for the <UserListings /> and <UserBookings /> components. If the userListings constant has a value (i.e. listings within user exists), we’ll have a userListingsElement be the <UserListings /> component. For the <UserListings /> component we want to render, we’ll pass in a few props that the component will eventually use such as userListings , listingsPage , limit , and the function necessary to update the listingsPage value - setListingsPage() .

// ...

const PAGE_LIMIT = 4;

export const User = ({ viewer, match }: Props & RouteComponentProps<MatchParams>) => {
  const [listingsPage, setListingsPage] = useState(1);
  const [bookingsPage, setBookingsPage] = useState(1);

  const { data, loading, error } = useQuery<UserData, UserVariables>(USER, {
    variables: {
      id: match.params.id,
      bookingsPage,
      listingsPage,
      limit: PAGE_LIMIT
    }
  });

  // ...

  const userListings = user ? user.listings : null;
  const userBookings = user ? user.bookings : null;

  // ...

  const userListingsElement = userListings ? (
    <UserListings
      userListings={userListings}
      listingsPage={listingsPage}
      limit={PAGE_LIMIT}
      setListingsPage={setListingsPage}
    />
  ) : null;

  // ...

  return (
    // ...
  );
};

We’ll create a similar userBookingsElement constant that is to be the <UserBookings /> component when the userBookings property has a value (i.e. bookings within user exists). The <UserBookings /> component will receive the following props - userbookings , bookingsPage , limit , and the setBookingsPage() function.

// ...

const PAGE_LIMIT = 4;

export const User = ({ viewer, match }: Props & RouteComponentProps<MatchParams>) => {
  const [listingsPage, setListingsPage] = useState(1);
  const [bookingsPage, setBookingsPage] = useState(1);

  const { data, loading, error } = useQuery<UserData, UserVariables>(USER, {
    variables: {
      id: match.params.id,
      bookingsPage,
      listingsPage,
      limit: PAGE_LIMIT
    }
  });

  // ...

  const userListings = user ? user.listings : null;
  const userBookings = user ? user.bookings : null;

  // ...

  const userListingsElement = userListings ? (
    <UserListings
      userListings={userListings}
      listingsPage={listingsPage}
      limit={PAGE_LIMIT}
      setListingsPage={setListingsPage}
    />
  ) : null;

  const userBookingsElement = userBookings ? (
    <UserBookings
      userBookings={userBookings}
      bookingsPage={bookingsPage}
      limit={PAGE_LIMIT}
      setBookingsPage={setBookingsPage}
    />
  ) : null;

  // ...

  return (
    // ...
  );
};

In the <User /> component’s return statement, we’ll render the userListingsElement and userBookingsElement within their own <Col /> 's. With all the changes made in the <User /> component, the src/sections/User/index.tsx will look like the following:

client/src/sections/User/index.tsx

import React, { useState } from "react";
import { RouteComponentProps } from "react-router-dom";
import { useQuery } from "@apollo/react-hooks";
import { Col, Layout, Row } from "antd";
import { USER } from "../../lib/graphql/queries";
import {
  User as UserData,
  UserVariables
} from "../../lib/graphql/queries/User/__generated__/User";
import { ErrorBanner, PageSkeleton } from "../../lib/components";
import { Viewer } from "../../lib/types";
import { UserBookings, UserListings, UserProfile } from "./components";

interface Props {
  viewer: Viewer;
}

interface MatchParams {
  id: string;
}

const { Content } = Layout;
const PAGE_LIMIT = 4;

export const User = ({ viewer, match }: Props & RouteComponentProps<MatchParams>) => {
  const [listingsPage, setListingsPage] = useState(1);
  const [bookingsPage, setBookingsPage] = useState(1);

  const { data, loading, error } = useQuery<UserData, UserVariables>(USER, {
    variables: {
      id: match.params.id,
      bookingsPage,
      listingsPage,
      limit: PAGE_LIMIT
    }
  });

  if (loading) {
    return (
      <Content className="user">
        <PageSkeleton />
      </Content>
    );
  }

  if (error) {
    return (
      <Content className="user">
        <ErrorBanner description="This user may not exist or we've encountered an error. Please try again soon." />
        <PageSkeleton />
      </Content>
    );
  }

  const user = data ? data.user : null;
  const viewerIsUser = viewer.id === match.params.id;

  const userListings = user ? user.listings : null;
  const userBookings = user ? user.bookings : null;

  const userProfileElement = user ? (
    <UserProfile user={user} viewerIsUser={viewerIsUser} />
  ) : null;

  const userListingsElement = userListings ? (
    <UserListings
      userListings={userListings}
      listingsPage={listingsPage}
      limit={PAGE_LIMIT}
      setListingsPage={setListingsPage}
    />
  ) : null;

  const userBookingsElement = userListings ? (
    <UserBookings
      userBookings={userBookings}
      bookingsPage={bookingsPage}
      limit={PAGE_LIMIT}
      setBookingsPage={setBookingsPage}
    />
  ) : null;

  return (
    <Content className="user">
      <Row gutter={12} type="flex" justify="space-between">
        <Col xs={24}>{userProfileElement}</Col>
        <Col xs={24}>
          {userListingsElement}
          {userBookingsElement}
        </Col>
      </Row>
    </Content>
  );
};

We’ll now look to create the <UserListings /> and <UserBookings /> child components we render within <User /> . We’ll create the folders for them in the components/ folder within src/sections/User/ .

client/
  // ...
  src/
    // ...
    sections/
      // ...
      User/
        components/
          UserListings/
            index.tsx
          UserBookings/
            index.tsx
          // ...
    // ...

In the src/sections/User/components/index.ts file, we’ll re-export the soon to be created <UserListings /> and <UserBookings /> components.

client/src/sections/User/components/index.ts

export * from "./UserBookings";
export * from "./UserListings";

<USERLISTINGS />

We’ll begin with the <UserListings /> component. The main component we’re going to use from Ant Design to help us create <UserListings /> is the powerful <List /> component.

The properties we’re interested in using from Ant Design’s <List /> component is:

  • The grid prop that helps control the structure of the grid.
  • The dataSource prop which would be the list of data that is going to be iterated and displayed in the list.
  • The renderItem prop which is a prop function that determines how every item of the list is going to be rendered. We’ll be interested in rendering the custom <ListingCard /> component for every item in the list.
  • The pagination prop to help set-up the list’s pagination configuration. The pagination prop in the <List /> component is adapted from Ant Design’s <Pagination /> component which will allow us to specify the current page, the total number of contents, the default page size, and so on.

In the <UserListings /> component file ( src/sections/User/components/UserListings/index.tsx ), let’s begin by first importing what we’ll need. We’ll import the <List /> and <Typography /> components from Ant Design. We’ll import the <ListingCard /> component from our src/lib/components/ folder. We’ll also import the autogenerated User interface for the user data being queried.

import React from "react";
import { List, Typography } from "antd";
import { ListingCard } from "../../../../lib/components";
import { User } from "../../../../lib/graphql/queries/User/__generated__/User";

We’ll then declare the props that the <UserListings /> component is to accept - userListings , listingsPage , limit , and setListingsPage . listingsPage and limit will be numbers while setListingsPage will be a function that accepts a number argument and returns void . For the userListings prop, we’ll declare a lookup type to access the interface type of the listings field within the User data interface used to describe the shape of data from the user query.

We’ll also destruct the <Paragraph /> and <Title /> child components from <Typography /> .

import React from "react";
import { List, Typography } from "antd";
import { ListingCard } from "../../../../lib/components";
import { User } from "../../../../lib/graphql/queries/User/__generated__/User";

interface Props {
  userListings: User["user"]["listings"];
  listingsPage: number;
  limit: number;
  setListingsPage: (page: number) => void;
}

const { Paragraph, Title } = Typography;

We’ll create and export the <UserListings /> component function and destruct the props we’ll need in the component. We’ll further destruct the total and result fields from the userListings prop object.

import React from "react";
import { List, Typography } from "antd";
import { ListingCard } from "../../../../lib/components";
import { User } from "../../../../lib/graphql/queries/User/__generated__/User";

interface Props {
  userListings: User["user"]["listings"];
  listingsPage: number;
  limit: number;
  setListingsPage: (page: number) => void;
}

const { Paragraph, Title } = Typography;

export const UserListings = ({
  userListings,
  listingsPage,
  limit,
  setListingsPage
}: Props) => {
  const { total, result } = userListings;
};

We’ll first create the list element within a constant we’ll call userListingsList and we’ll use Ant Design’s <List /> component. Here’s how we plan on using each of the props we attempt to declare in the <List /> component.

grid

grid will be used to help set up the grid layout in different viewports.

  • The gutter property in grid helps introduce some spacing between columns so we’ll state a value of 8 .
  • The xs field dictates the number of columns to be shown in extra-small viewports with which we’ll provide a value of 1.
  • For small viewports (i.e. the sm grid field), we’ll provide a value 2 to show 2 items for the entire viewport.
  • For large viewports (i.e. the lg grid field), we’ll provide a value of 4 to show 4 items for the entire viewport.
// ...

export const UserListings = ({
  userListings,
  listingsPage,
  limit,
  setListingsPage
}: Props) => {
  const { total, result } = userListings;

  const userListingsList = (
    <List
      grid={{
        gutter: 8,
        xs: 1,
        sm: 2,
        lg: 4
      }}
    />
  );
};

dataSource

dataSource is the data we want to use for the list. We’ll pass in the result array from our userListings object which is the list of listings.

// ...

export const UserListings = ({
  userListings,
  listingsPage,
  limit,
  setListingsPage
}: Props) => {
  const { total, result } = userListings;

  const userListingsList = (
    <List
      grid={{
        gutter: 8,
        xs: 1,
        sm: 2,
        lg: 4
      }}
      dataSource={result}
    />
  );
};

locale

locale can help us introduce text for empty lists. For the value of locale , we’ll pass an object with an emptyText field that will say "User doesn't have any listings yet!" which will be shown when the user doesn’t have any listings.

// ...

export const UserListings = ({
  userListings,
  listingsPage,
  limit,
  setListingsPage
}: Props) => {
  const { total, result } = userListings;

  const userListingsList = (
    <List
      grid={{
        gutter: 8,
        xs: 1,
        sm: 2,
        lg: 4
      }}
      dataSource={result}
      locale={{ emptyText: "User doesn't have any listings yet!" }}
    />
  );
};

pagination

pagination is how we’ll set up the pagination element of our list. We’ll pass an object and the fields we’ll want to configure this.

  • position: "top" helps position it at the top.
  • current references the current page with which we’ll give a value of the listingsPage prop.
  • total references the total number of items to be paginated with which we’ll give a value of the total field from our query.
  • defaultPageSize helps determine the default page size with which we’ll pass a value of the limit prop (which is 4).
  • hideOnSinglePage helps us hide the pagination element when there’s only one page and since we’ll want it, we’ll give this a value of true .
  • showLessItems helps construct the pagination element in a way that not all page numbers are shown for very large lists. With showLessItems , only the pages around the current page and the boundary pages will be shown which is something we’ll want as well.
  • Finally, in the pagination options there exists an onChange() callback function that runs when the user clicks a page number. We’ll declare the onChange() callback function, take the payload of the callback (which will be the new page number), and run the setListingsPage() function we have that will update the listingsPage state value in the parent.
// ...

export const UserListings = ({
  userListings,
  listingsPage,
  limit,
  setListingsPage
}: Props) => {
  const { total, result } = userListings;

  const userListingsList = (
    <List
      grid={{
        gutter: 8,
        xs: 1,
        sm: 2,
        lg: 4
      }}
      dataSource={result}
      locale={{ emptyText: "User doesn't have any listings yet!" }}
      pagination={{
        position: "top",
        current: listingsPage,
        total,
        defaultPageSize: limit,
        hideOnSinglePage: true,
        showLessItems: true,
        onChange: (page: number) => setListingsPage(page)
      }}
    />
  );
};

renderItem

The last prop we’ll declare in the <List /> component is the renderItem prop which takes every item within the data source and determines the UI to be displayed for each list item. We’ll keep it simple and say we’ll render a <ListingCard /> component for each item with which we’ll pass the iterated listing as props. We’ll declare the rendered <ListingCard /> within the <List.Item /> component.

// ...

export const UserListings = ({
  userListings,
  listingsPage,
  limit,
  setListingsPage
}: Props) => {
  const { total, result } = userListings;

  const userListingsList = (
    <List
      grid={{
        gutter: 8,
        xs: 1,
        sm: 2,
        lg: 4
      }}
      dataSource={result}
      locale={{ emptyText: "User doesn't have any listings yet!" }}
      pagination={{
        position: "top",
        current: listingsPage,
        total,
        defaultPageSize: limit,
        hideOnSinglePage: true,
        showLessItems: true,
        onChange: (page: number) => setListingsPage(page)
      }}
      renderItem={userListing => (
        <List.Item>
          <ListingCard listing={userListing} />
        </List.Item>
      )}
    />
  );
};

With our list element created, we can render it in our <UserListings /> component’s return statement. We’ll return some markup providing a title and a description for what this listings section is and we’ll return the userListingsList element. With all these changes, the <UserListings /> component file will appear as the following:

client/src/sections/User/components/UserListings/index.tsx

import React from "react";
import { List, Typography } from "antd";
import { ListingCard } from "../../../../lib/components";
import { User } from "../../../../lib/graphql/queries/User/__generated__/User";

interface Props {
  userListings: User["user"]["listings"];
  listingsPage: number;
  limit: number;
  setListingsPage: (page: number) => void;
}

const { Paragraph, Title } = Typography;

export const UserListings = ({
  userListings,
  listingsPage,
  limit,
  setListingsPage
}: Props) => {
  const { total, result } = userListings;

  const userListingsList = (
    <List
      grid={{
        gutter: 8,
        xs: 1,
        sm: 2,
        lg: 4
      }}
      dataSource={result}
      locale={{ emptyText: "User doesn't have any listings yet!" }}
      pagination={{
        position: "top",
        current: listingsPage,
        total,
        defaultPageSize: limit,
        hideOnSinglePage: true,
        showLessItems: true,
        onChange: (page: number) => setListingsPage(page)
      }}
      renderItem={userListing => (
        <List.Item>
          <ListingCard listing={userListing} />
        </List.Item>
      )}
    />
  );

  return (
    <div className="user-listings">
      <Title level={4} className="user-listings__title">
        Listings
      </Title>
      <Paragraph className="user-listings__description">
        This section highlights the listings this user currently hosts and has
        made available for bookings.
      </Paragraph>
      {userListingsList}
    </div>
  );
};

This will be the UI we’ll need for our <UserListings /> component.

<USERBOOKINGS />

The <UserBookings /> component will be very similar to the <UserListings /> component. In the src/sections/User/components/UserBookings/index.tsx file, we’ll import the <List /> and <Typography /> components from Ant Design, we’ll import the <ListingCard /> component from the src/lib/components/ folder, and we’ll import the autogenerated TypeScript interface for the data obtained from the user query. We’ll destruct the <Text /> , <Paragraph /> , and <Title /> from <Typography /> .

We’ll also reference the appropriate props the <UserBookings /> component is to receive which is to be userBookings , bookingsPage , limit , and the setBookingsPage() function.

import React from "react";
import { List, Typography } from "antd";
import { ListingCard } from "../../../../lib/components";
import { User } from "../../../../lib/graphql/queries/User/__generated__/User";

interface Props {
  userBookings: User["user"]["bookings"];
  bookingsPage: number;
  limit: number;
  setBookingsPage: (page: number) => void;
}

const { Paragraph, Text, Title } = Typography;

export const UserBookings = ({
  userBookings,
  bookingsPage,
  limit,
  setBookingsPage
}: Props) => {};

In the <UserBookings /> component, we won’t be able to destruct the total and result value from the userBookings prop directly since userBookings might be null. userBookings will be null when a viewer views the user page of another user.

At the beginning of the <UserBookings /> component function, we’ll use ternary statements to determine the values of the total and result constants which will be null if userBookings is ever null .

// ...

export const UserBookings = ({
  userBookings,
  bookingsPage,
  limit,
  setBookingsPage
}: Props) => {
  const total = userBookings ? userBookings.total : null;
  const result = userBookings ? userBookings.result : null;
};

We’ll then create an element for the list of bookings which we’ll call userBookingsList . This element will only be conditionally shown if the total and result values exist. The list element we’ll create for userBookingsList will be very similar to that created in the <UserListings /> component except that we’ll reference the appropriate prop values for the pagination object - bookingsPage and setBookingsPage .

// ...

export const UserBookings = ({
  userBookings,
  bookingsPage,
  limit,
  setBookingsPage
}: Props) => {
  const total = userBookings ? userBookings.total : null;
  const result = userBookings ? userBookings.result : null;

  const userBookingsList = userBookings ? (
    <List
      grid={{
        gutter: 8,
        xs: 1,
        sm: 2,
        lg: 4
      }}
      dataSource={result ? result : undefined}
      locale={{ emptyText: "You haven't made any bookings!" }}
      pagination={{
        position: "top",
        current: bookingsPage,
        total: total ? total : undefined,
        defaultPageSize: limit,
        hideOnSinglePage: true,
        showLessItems: true,
        onChange: (page: number) => setBookingsPage(page)
      }}
      renderItem={}
    />
  ) : null;
};

In the renderItem prop of the <List /> in userBookingsList , the difference we’ll make here from the list shown in the <UserListings /> is that we’ll now display booking history above the listing card. In the renderItem prop function, we’ll create this bookingHistory element within a constant that will simply show the checkIn and checkOut values and we’ll render it above the <ListingCard /> shown for each list item.

// ...

export const UserBookings = ({
  userBookings,
  bookingsPage,
  limit,
  setBookingsPage
}: Props) => {
  const total = userBookings ? userBookings.total : null;
  const result = userBookings ? userBookings.result : null;

  const userBookingsList = userBookings ? (
    <List
      grid={{
        gutter: 8,
        xs: 1,
        sm: 2,
        lg: 4
      }}
      dataSource={result ? result : undefined}
      locale={{ emptyText: "You haven't made any bookings!" }}
      pagination={{
        position: "top",
        current: bookingsPage,
        total: total ? total : undefined,
        defaultPageSize: limit,
        hideOnSinglePage: true,
        showLessItems: true,
        onChange: (page: number) => setBookingsPage(page)
      }}
      renderItem={userBooking => {
        const bookingHistory = (
          <div className="user-bookings__booking-history">
            <div>
              Check in: <Text strong>{userBooking.checkIn}</Text>
            </div>
            <div>
              Check out: <Text strong>{userBooking.checkOut}</Text>
            </div>
          </div>
        );

        return (
          <List.Item>
            {bookingHistory}
            <ListingCard listing={userBooking.listing} />
          </List.Item>
        );
      }}
    />
  ) : null;
};

Instead of returning the userBookingsList element in the return statement of <UserBookings /> directly, we’ll have a conditionally rendered element called userBookingsElement that will be shown if the userBookings prop is available. By creating and returning this userBookingsElement and with the all the changes we’ve made, our src/sections/User/components/UserBookings/index.tsx file will look like the following:

client/src/sections/User/components/UserBookings/index.tsx

import React from "react";
import { List, Typography } from "antd";
import { ListingCard } from "../../../../lib/components";
import { User } from "../../../../lib/graphql/queries/User/__generated__/User";

interface Props {
  userBookings: User["user"]["bookings"];
  bookingsPage: number;
  limit: number;
  setBookingsPage: (page: number) => void;
}

const { Paragraph, Text, Title } = Typography;

export const UserBookings = ({
  userBookings,
  bookingsPage,
  limit,
  setBookingsPage
}: Props) => {
  const total = userBookings ? userBookings.total : null;
  const result = userBookings ? userBookings.result : null;

  const userBookingsList = userBookings ? (
    <List
      grid={{
        gutter: 8,
        xs: 1,
        sm: 2,
        lg: 4
      }}
      dataSource={result ? result : undefined}
      locale={{ emptyText: "You haven't made any bookings!" }}
      pagination={{
        position: "top",
        current: bookingsPage,
        total: total ? total : undefined,
        defaultPageSize: limit,
        hideOnSinglePage: true,
        showLessItems: true,
        onChange: (page: number) => setBookingsPage(page)
      }}
      renderItem={userBooking => {
        const bookingHistory = (
          <div className="user-bookings__booking-history">
            <div>
              Check in: <Text strong>{userBooking.checkIn}</Text>
            </div>
            <div>
              Check out: <Text strong>{userBooking.checkOut}</Text>
            </div>
          </div>
        );

        return (
          <List.Item>
            {bookingHistory}
            <ListingCard listing={userBooking.listing} />
          </List.Item>
        );
      }}
    />
  ) : null;

  const userBookingsElement = userBookingsList ? (
    <div className="user-bookings">
      <Title level={4} className="user-bookings__title">
        Bookings
      </Title>
      <Paragraph className="user-bookings__description">
        This section highlights the bookings you've made, and the check-in/check-out dates
        associated with said bookings.
      </Paragraph>
      {userBookingsList}
    </div>
  ) : null;

  return userBookingsElement;
};

And that’s it! We’ve created the <UserListings /> and <UserBookings /> components. In the <User /> component file, let’s import these newly created components and save our changes.

client/src/sections/User/index.tsx

import { UserBookings, UserListings, UserProfile } from "./components";

At this moment in time, the user you’ve signed in with won’t have any listings or bookings data. To verify that at least the <UserListings /> component works as intended, we’ll grab an id of a user from our mock users collection in the database and visit the user page directly based on the route.

/user/5d378db94e84753160e08b56 # example path for user in mock data that has listings

And… we see an error!

Why is this error being shown? There are a few things we’ve missed on implementing on the server with regards to how the listings and bookings fields in our GraphQL API are to be resolved.

LISTINGRESOLVERS | BOOKINGRESOLVERS - SERVER UPDATE

:running_man: At this moment, we’ll switch over to briefly working on our Node server project.

In the server project, we’ve defined resolver functions for the user object. The listings and bookings field in our user object are to resolve to the Listing and Booking GraphQL object types. We haven’t defined any resolver functions for the Listing and Booking GraphQL objects . Certain trivial resolver functions are handled for us by Apollo Server, however, we’ll need to set up explicit resolver functions for a few fields.

listingResolvers

We’ll begin by creating resolver functions for the Listing GraphQL object. We’ll create a Listing/ folder that is to have an index.ts file within the src/graphql/resolvers/ folder.

server/
  src/
    resolvers/
      // ...
      Listing/
        index.ts
      // ...
    // ..
  // ...

In the src/graphql/resolvers/Listing/index.ts file, we’ll create the listingResolvers map for the Listing GraphQL object type.

import { IResolvers } from "apollo-server-express";

export const listingResolvers: IResolvers = {};

Within listingResolvers , we’ll specify the id() resolver function that will simply return the value of the _id field of the listing object in string format.

server/src/graphql/resolvers/Listing/index.ts

import { IResolvers } from "apollo-server-express";
import { Listing } from "../../../lib/types";

export const listingResolvers: IResolvers = {
  Listing: {
    id: (listing: Listing): string => {
      return listing._id.toString();
    }
  }
};

Note: A listing document in our database differs from a user document in our database since _id for a listing document is an ObjectId and not a string. This is why we’ve used the toString() helper to convert the _id value to a string as we resolve the id field for a Listing GraphQL object.

There will be other resolvers we’ll eventually need to create for the Listing object but for the fields we’re attempting to access for the /user/:id page in our client, the id() resolver is the only one we’ll explicitly need to create. The other fields being queried for the Listing object in the /user/:id are being handled as trivial resolvers.

In the src/graphql/resolvers/index.ts file, we’ll import the listingResolvers map and place it in the Lodash merge() function

import merge from "lodash.merge";
import { listingResolver } from "./Listing";
import { userResolver } from "./User";
import { viewerResolver } from "./Viewer";

export const resolvers = merge(listingResolver, userResolver, viewerResolver);

bookingResolvers

We’ll also need to create a bookingResolvers map and some resolver functions for a few fields we query for in the Booking object in the /user/:id route.

In src/graphql/resolvers/ , we’ll create a Booking/ folder that is to have an index.ts file.

server/
  src/
    resolvers/
      // ...
      Booking/
        index.ts
      // ...
    // ..
  // ...

In the src/graphql/resolvers/Booking/index.ts file, we’ll create a bookingsResolver map. We’ll define a Booking object and specify the id() resolver function similar to how we’ve done in the listingResolvers map.

import { IResolvers } from "apollo-server-express";
import { Booking } from "../../../lib/types";

export const bookingResolvers: IResolvers = {
  Booking: {
    id: (booking: Booking): string => {
      return booking._id.toString();
    }
  }
};

There’s one other field we’re querying for in our client, for the Booking GraphQL object, that we’ll need an explicit resolver function for as well. In the /user/:id page, we’re querying for the listing field from the booking object expecting a listing object that summarizes the listing details of a booking. In the booking document in our database, we store listing as an id value but in the client we expect a listing object.

With that said, we’ll create a resolver for the listing field in the Booking object to find a single listing document from the listings collection where the value of the listing _id is equivalent to the id value of the booking.listing field.

server/src/graphql/resolvers/Booking/index.ts

import { IResolvers } from "apollo-server-express";
import { Database, Listing, Booking } from "../../../lib/types";

export const bookingResolvers: IResolvers = {
  Booking: {
    id: (booking: Booking): string => {
      return booking._id.toString();
    },
    listing: (
      booking: Booking,
      _args: {},
      { db }: { db: Database }
    ): Promise<Listing | null> => {
      return db.listings.findOne({ _id: booking.listing });
    }
  }
};

We’ll now import the bookingResolvers map in the src/resolvers/index.ts file, and place it in the Lodash merge() function.

server/src/graphql/resolvers/index.ts

import merge from "lodash.merge";
import { bookingResolvers } from "./Booking";
import { listingResolvers } from "./Listing";
import { userResolvers } from "./User";
import { viewerResolvers } from "./Viewer";

export const resolvers = merge(
  bookingResolvers,
  listingResolvers,
  userResolvers,
  viewerResolvers
);

When we save our changes in the server and visit the same user page we attempted to visit before in the client application, we’ll be presented with some listing cards for each listing that this user has.

<LISTINGCARD />

:running_man: We’ll switch back to working on our React client project.

There are a few more improvements we’ll make before we discuss a few interesting points and close this lesson.

  • All pricing information stored in our database is in cents . We’ll want to display pricing information for listings in the listing cards in dollar format.
  • For the icon shown in the <ListingCard /> , we’ll like to show a nice blue color that matches the primary color being used in our application.

We’ll prepare a function to format currency and a constant to represent the icon color we’ll want to show in our app in the src/lib/utils/index.ts file where we keep shared app functions.

We’ll first create a formatListingPrice() function that’ll take two arguments:

  • price which is a number
  • and a round boolean which we’ll default to true .
// ...

export const formatListingPrice = (price: number, round = true) => {};

// ...

In most areas of our app, we’ll want to format the currency to dollars. We’ll also usually want price values rounded to a whole number since it’ll more presentable that way which is why we’ll have round as a function argument to control this.

The formatListingPrice() function will simply return a string containing the dollar symbol and a formatListingPrice value which will get the dollar value and be rounded depending on the value of the round argument.

client/src/lib/utils/index.ts

export const formatListingPrice = (price: number, round = true) => {
  const formattedListingPrice = round ? Math.round(price / 100) : price / 100;
  return `${formattedListingPrice}`;
};

We’ll also export an iconColor value which will simply be a string to represent the primary hex color of our app.

client/src/lib/utils/index.ts

export const iconColor = "#1890ff";

With these changes, our src/lib/utils/index.ts file will look like the following:

client/src/lib/utils/index.ts

import { message, notification } from "antd";

export const iconColor = "#1890ff";

export const formatListingPrice = (price: number, round = true) => {
  const formattedListingPrice = round ? Math.round(price / 100) : price / 100;
  return `${formattedListingPrice}`;
};

export const displaySuccessNotification = (
  message: string,
  description?: string
) => {
  return notification["success"]({
    message,
    description,
    placement: "topLeft",
    style: {
      marginTop: 50
    }
  });
};

export const displayErrorMessage = (error: string) => {
  return message.error(error);
};

In our <ListingCard /> component file, we’ll import the iconColor and formatListingPrice() function from the src/lib/utils/index.ts file.

client/src/lib/components/ListingCard/index.tsx

import { iconColor, formatListingPrice } from "../../utils";

We’ll make the following changes to the <ListingCard /> component.

  • We’ll use the formatListingPrice() function to show the price in the <ListingCard /> template. By not passing in a second argument, it’ll default to show a rounded value.
  • We’ll apply a style to the <Icon /> element to specify the color to be the iconColor value we’ve set up in the src/lib/utils/index.ts file.
  • We want listing cards to be links to the listing page of a particular listing. To make this happen, we’ll import the <Link /> component from react-router-dom , we’ll destruct the id of a listing from the listing prop object, and we’ll wrap our <ListingCard /> component return statement with the <Link /> component with a target route of /listing/${id} .

With these changes made to the <ListingCard /> component, the src/lib/components/ListingCard/index.tsx file will look like the following.

client/src/lib/components/ListingCard/index.tsx

import React from "react";
import { Link } from "react-router-dom";
import { Card, Icon, Typography } from "antd";
import { iconColor, formatListingPrice } from "../../utils";

interface Props {
  listing: {
    id: string;
    title: string;
    image: string;
    address: string;
    price: number;
    numOfGuests: number;
  };
}

const { Text, Title } = Typography;

export const ListingCard = ({ listing }: Props) => {
  const { id, title, image, address, price, numOfGuests } = listing;

  return (
    <Link to={`/listing/${id}`}>
      <Card
        hoverable
        cover={
          <div
            style={{ backgroundImage: `url(${image})` }}
            className="listing-card__cover-img"
          />
        }
      >
        <div className="listing-card__details">
          <div className="listing-card__description">
            <Title level={4} className="listing-card__price">
              {formatListingPrice(price)}
              <span>/day</span>
            </Title>
            <Text strong ellipsis className="listing-card__title">
              {title}
            </Text>
            <Text ellipsis className="listing-card__address">
              {address}
            </Text>
          </div>
          <div className="listing-card__dimensions listing-card__dimensions--guests">
            <Icon type="user" style={{ color: iconColor }} />
            <Text>{numOfGuests} guests</Text>
          </div>
        </div>
      </Card>
    </Link>
  );
};

When we now take a look at the same user page we’ve viewed before in our client application, we can see that the pricing details now show the dollar symbol. The price of each listing is in dollars per day. In addition, the listing cards are now links that when clicked will take the user to that /listing/:id page.

PAGINATION

Changing variables in useQuery Hook

We can see the pagination element shown when a user has more than 4 listings in total. If we were to click another page, our user page is loaded again and we’ll see another page of listings! Amazing!

If the page is loading and we see a new set of listings, this probably means we’re made another query, and we can confirm this from our browser’s network tab. With that said, why is another query for the user happening when we switch pages?

When we take a look at the use of the useQuery Hook in the <User /> component, we’ve come to understand the useQuery Hook makes the request when the component mounts for the first time. When we click a new page button, the only thing that’s changed is the value of the listingsPage or bookingsPage state.

The useQuery Hook in React Apollo is smart enough to make another query request when the variables of the query changes , and this occurs by default . This is especially helpful because our component is prepared to consume the data , loading , and error properties of our query result anytime the query is made again.

When our query is in flight again, loading in the <User /> component is set to true and our page shows the loading skeleton. When the new query is complete - we see the new list of listings.

Apollo Client Cache

There’s another important note we should talk about. If we head back to the client and try to navigate to a listing page we’ve already visited, our UI updates to show the listings that intend to be shown but a request isn’t being made again .

How is this working? This is due to something we’ve briefly mentioned before but now can witness. Apollo Client doesn’t only give us useful methods to conduct data fetching but sets up an in-memory intelligent cache without any configuration on our part .

When we make requests to retrieve data with Apollo Client, Apollo Client under the hood caches the data . The next time we return to the page that we’ve just visited, Apollo Client is smart enough to say - “Hey, we already have this data in the cache. Let’s just provide the data from the cache directly without needing to make another request to the server”. This saves time and helps avoid the unnecessary re-request of data from the server that we’ve already requested before.

Apollo Client also gives us the capability to directly update information in the cache when needed.

Are there ways we can tell Apollo Client to force the request from the network and not from the cache? Yes, and we’ll see examples of this later in the course.

Okay. We’re going to stop here for now. Great job so far! The UI we’ve built for the /user/:id page will be very similar to the other pages we intend to build in our client application.

MODULE 6 SUMMARY

In this module, we had the client be able to request and present information for a certain user in the /user/:id route of our application.

SERVER PROJECT

SRC/GRAPHQL/TYPEDEFS.TS

We created a single root-level user field that can be queried from the client to receive a user’s information. The user query field queries for a user in the "users" collection of our database based on the id argument provided.

server/src/graphql/typeDefs.ts

  type Query {
    authUrl: String!
    user(id: ID!): User!
  }

The User object returned from the user query field is to have certain information about a user we want the client to access.

server/src/graphql/typeDefs.ts

  type User {
    id: ID!
    name: String!
    avatar: String!
    contact: String!
    hasWallet: Boolean!
    income: Int
    bookings(limit: Int!, page: Int!): Bookings
    listings(limit: Int!, page: Int!): Listings!
  }

SRC/GRAPHQL/RESOLVERS/USER/INDEX.TS

The userResolvers map in the src/graphql/resolvers/User/index.ts file contains the resolver functions that pertain to the User object/domain.

The root-level query user() resolver function simply looks to find the user from the "users" collection from the id argument provided. If the viewer making the request is the user being requested, we add an authorized property to the user object to constitute that the viewer is authorized to see certain information about the user .

server/src/graphql/resolvers/User/index.ts

export const userResolvers: IResolvers = {  Query: {
    user: async (
      _root: undefined,
      { id }: UserArgs,
      { db, req }: { db: Database; req: Request }
    ): Promise<User> => {
      try {
        const user = await db.users.findOne({ _id: id });

        if (!user) {
          throw new Error("user can't be found");
        }

        const viewer = await authorize(db, req);

        if (viewer && viewer._id === user._id) {
          user.authorized = true;
        }

        return user;
      } catch (error) {
        throw new Error(`Failed to query user: ${error}`);
      }
    }
  },};

We declare a few other additional resolver functions for the User object. The id() resolver simply returns the _id value of a user document. The hasWallet() resolver returns the presence of the walletId field of a user document. The income field returns the user.income only if the viewer is authorized to see this information.

server/src/graphql/resolvers/User/index.ts

export const userResolvers: IResolvers = {    id: (user: User): string => {
      return user._id;
    },
    hasWallet: (user: User): boolean => {
      return Boolean(user.walletId);
    },
    income: (user: User): number | null => {
      return user.authorized ? user.income : null;
    },};

bookings() and listings() are resolver functions that help return a paginated list of bookings and listings respectively. The bookings() resolver only returns data if the viewer is authorized.

server/src/graphql/resolvers/User/index.ts

export const userResolvers: IResolvers = {    bookings: async (
      user: User,
      { limit, page }: UserBookingsArgs,
      { db }: { db: Database }
    ): Promise<UserBookingsData | null> => {
      try {
        if (!user.authorized) {
          return null;
        }

        const data: UserBookingsData = {
          total: 0,
          result: []
        };

        let cursor = await db.bookings.find({
          _id: { $in: user.bookings }
        });

        cursor = cursor.skip(page > 0 ? (page - 1) * limit : 0);
        cursor = cursor.limit(limit);

        data.total = await cursor.count();
        data.result = await cursor.toArray();

        return data;
      } catch (error) {
        throw new Error(`Failed to query user bookings: ${error}`);
      }
    },};

server/src/graphql/resolvers/User/index.ts

export const userResolvers: IResolvers = {    listings: async (
      user: User,
      { limit, page }: UserListingsArgs,
      { db }: { db: Database }
    ): Promise<UserListingsData | null> => {
      try {
        const data: UserListingsData = {
          total: 0,
          result: []
        };

        let cursor = await db.listings.find({
          _id: { $in: user.listings }
        });

        cursor = cursor.skip(page > 0 ? (page - 1) * limit : 0);
        cursor = cursor.limit(limit);

        data.total = await cursor.count();
        data.result = await cursor.toArray();

        return data;
      } catch (error) {
        throw new Error(`Failed to query user listings: ${error}`);
      }
    }};

SRC/GRAPHQL/RESOLVERS/BOOKING/INDEX.TS

We’ve created explicit resolver functions for the id and listing field in a Booking object to have these fields resolve to their expected values when queried from the client.

server/src/graphql/resolvers/Booking/index.ts

export const bookingResolvers: IResolvers = {
  Booking: {
    id: (booking: Booking): string => {
      return booking._id.toString();
    },
    listing: (
      booking: Booking,
      _args: {},
      { db }: { db: Database }
    ): Promise<Listing | null> => {
      return db.listings.findOne({ _id: booking.listing });
    }
  }
};

SRC/GRAPHQL/RESOLVERS/LISTING/INDEX.TS

We’ve also created an explicit resolver function for the id field of a Listing object to have it resolved to its expected value when queried from the client.

server/src/graphql/resolvers/Listing/index.ts

export const listingResolvers: IResolvers = {
  Listing: {
    id: (listing: Listing): string => {
      return listing._id.toString();
    }
  }
};

CLIENT PROJECT

SRC/LIB/GRAPHQL/QUERIES/USER/INDEX.TS

In the client, we constructed the User GraphQL document in the src/lib/graphql/queries/User/index.ts file.

client/src/lib/graphql/queries/User/index.ts

import { gql } from "apollo-boost";

export const USER = gql`
  query User($id: ID!, $bookingsPage: Int!, $listingsPage: Int!, $limit: Int!) {
    user(id: $id) {
      id
      name
      avatar
      contact
      hasWallet
      income
      bookings(limit: $limit, page: $bookingsPage) {
        total
        result {
          id
          listing {
            id
            title
            image
            address
            price
            numOfGuests
          }
          checkIn
          checkOut
        }
      }
      listings(limit: $limit, page: $listingsPage) {
        total
        result {
          id
          title
          image
          address
          price
          numOfGuests
        }
      }
    }
  }
`;

SRC/SECTIONS/USER/INDEX.TSX

In the <User /> component rendered in the /user/:id route, we construct the entire user page that involves but is not limited to:

  • Making the user query when the component first mounts.
  • Presenting the user profile information in the <UserProfile /> child component.
  • Presenting the list of listings the user owns in the <UserListings /> child component.
  • Presenting the list of bookings the user has made in the <UserBookings /> child component.

Appropriate loading and error state UI is also presented when the user query is in the loading or error state. When data is available from the query, the relevant user information is shown to the user.

client/src/sections/User/index.tsx

export const User = ({ viewer, match }: Props & RouteComponentProps<MatchParams>) => {
  const [listingsPage, setListingsPage] = useState(1);
  const [bookingsPage, setBookingsPage] = useState(1);

  const { data, loading, error } = useQuery<UserData, UserVariables>(USER, {
    variables: {
      id: match.params.id,
      bookingsPage,
      listingsPage,
      limit: PAGE_LIMIT
    }
  });

  if (loading) {
    return (
      <Content className="user">
        <PageSkeleton />
      </Content>
    );
  }

  if (error) {
    return (
      <Content className="user">
        <ErrorBanner description="This user may not exist or we've encountered an error. Please try again soon." />
        <PageSkeleton />
      </Content>
    );
  }

  const user = data ? data.user : null;
  const viewerIsUser = viewer.id === match.params.id;

  const userListings = user ? user.listings : null;
  const userBookings = user ? user.bookings : null;

  const userProfileElement = user ? (
    <UserProfile user={user} viewerIsUser={viewerIsUser} />
  ) : null;

  const userListingsElement = userListings ? (
    <UserListings
      userListings={userListings}
      listingsPage={listingsPage}
      limit={PAGE_LIMIT}
      setListingsPage={setListingsPage}
    />
  ) : null;

  const userBookingsElement = userListings ? (
    <UserBookings
      userBookings={userBookings}
      bookingsPage={bookingsPage}
      limit={PAGE_LIMIT}
      setBookingsPage={setBookingsPage}
    />
  ) : null;

  return (
    <Content className="user">
      <Row gutter={12} type="flex" justify="space-between">
        <Col xs={24}>{userProfileElement}</Col>
        <Col xs={24}>
          {userListingsElement}
          {userBookingsElement}
        </Col>
      </Row>
    </Content>
  );
};

MOVING FORWARD

In the next module, we begin building the server and client implementation that will help allow us to retrieve and display information for listings in the /listing/:id route of our application.

MODULE 7 INTRODUCTION

In this module, we’ll look to have listing information be queried from the database and shown in the Listing page of our application. The Listing page is where a user can see details about a certain listing and where the user will eventually be able to book that listing for a period of time.

The Listing page is to be displayed in the /listing/:id route of our app and in its complete state will look similar to the following:

In this module, we’ll:

  • Update our GraphQL API to query information for a specific listing from our database.
  • Build the UI of the Listing page in our client project.

LISTING GRAPHQL FIELDS

In the last module, we managed to build the server GraphQL fields and client representation of a user. In this module, we’ll look to build the server and client portions of getting information for a single listing . To display information about our listings in our client app, we will need some kind of functionality to query and resolve listing data on our server.

Just like how we have a single root-level field to query for a user, we’ll have a single root-level field to query for a certain listing. The one thing we’ll keep in mind with our listing query field is that we should only return the sensitive portions of the listing data if the user is requesting their own listing. The sensitive data we’re referring to is the bookings made for a certain listing. Only the user querying for their own listing page should be able to access the bookings made to their listing. We’ll implement this authorization in the coming lessons.

Let’s first prepare the listing GraphQL field and the accompanying resolver function. We’ll first define the listings field in the root Query object of our GraphQL API and we’ll say, for now, that its expected return type is a defined string value.

server/src/graphql/typeDefs.ts

  type Query {
    authUrl: String!
    user(id: ID!): User!
    listing: String!
  }

Next, we’ll construct the resolver for the listing query field. When we built the user module, we defined the structure of a Listing GraphQL object since listings can be queried within a user. As a result, we also had to define the explicit resolver function for the id field for the Listing object, so we created the listingResolvers map to contain the resolvers for the listing module.

In the listingResolvers map within the src/graphql/resolvers/Listing/index.ts file, we’ll now create the resolver for the root level listing field from the Query root object and we’ll say, at this moment, it is to return a string that is to say Query.listing .

server/src/graphql/resolvers/Listing/index.ts

import { IResolvers } from "apollo-server-express";
import { Listing } from "../../../lib/types";

export const listingResolvers: IResolvers = {
  Query: {
    listing: () => {
      return "Query.listing";
    }
  },
  Listing: {
    id: (listing: Listing): string => {
      return listing._id.toString();
    }
  }
};

With our server project running, if we head over to the GraphQL Playground at http://localhost:9000/api and execute the listing query, we’ll see the "Query.listing" output. In the next lesson, we’ll build the functionality for the listing() resolver to actually query for listing information from the database.

BUILDING THE LISTING RESOLVERS

With the root-level listing field prepared in our GraphQL API, we’ll construct the resolver function for this field to attempt to query for the appropriate listing from the listings collection in our database. Similar to how the user query field queried for a user from our database with a certain ID, the listing query field will query for a certain listing based on the ID provided.

As a result, we’ll update the listing field in our GraphQL type definitions and state it expects a defined argument of id of type GraphQL ID. In addition, the listing field when resolved should return the appropriate Listing GraphQL object.

server/src/graphql/typeDefs.ts

  type Query {
    authUrl: String!
    user(id: ID!): User!
    listing(id: ID!): Listing!
  }

This Listing GraphQL object has been created in the last module and has fields to describe the certain listing - such as it’s title , description , host , etc.

LISTING()

We’ll now modify the resolver function for the listing field to state that it is to accept an id input from our client and return a Listing object when resolved. First, we’ll construct the interface of the expected arguments for this field in a types.ts file kept within the src/graphql/resolvers/Listing folder. We’ll create a ListingArgs interface that is to have an id field of type string .

server/src/graphql/resolvers/Listing/types.ts

export interface ListingArgs {
  id: string;
}

In our listingResolvers map within the src/graphql/resolvers/Listing/index.ts file, we’ll import a few things we’ll need for our listing() resolver function. We’ll first import the Database and Listing interfaces that have been defined in the src/lib/types.ts file. We’ll also import the recently created ListingArgs interface from the types.ts file adjacent to this file.

// ...
import { Database, Listing } from "../../../lib/types";
import { ListingArgs } from "./types";

We’ll now update the listing() resolver function to query for a certain listing from the database.

  • We’ll define the listing() resolver function as an async function.
  • In the listing() resolver function, we’ll access the id argument passed in and the db object available in the context of our resolver.
  • When the listing() resolver function is to be complete, it should return a Promise that when resolved is an object of type Listing .
// ...

export const listingResolvers: IResolvers = {
  Query: {
    listing: async (
      _root: undefined,
      { id }: ListingArgs,
      { db, req }: { db: Database; req: Request }
    ): Promise<Listing> => {}
  },
  Listing: {
    // ...
  }
};

The listing() resolver function will be fairly straightforward to implement. We’ll use Mongo’s findOne() method to find a listing document from the listings collection where the _id field is the ObjectId representation of the id argument passed in. If this listing document doesn’t exist, we’ll throw a new Error . If the listing document does exist, we’ll return the listing document that’s been found. We’ll have this implementation be kept in a try block while in a catch statement - we’ll catch an error if ever to arise and have it thrown within a new error message.

// ...
import { ObjectId } from "mongodb";
// ...

export const listingResolvers: IResolvers = {
  Query: {
    listing: async (
      _root: undefined,
      { id }: ListingArgs,
      { db, req }: { db: Database; req: Request }
    ): Promise<Listing> => {
      try {
        const listing = await db.listings.findOne({ _id: new ObjectId(id) });
        if (!listing) {
          throw new Error("listing can't be found");
        }

        return listing;
      } catch (error) {
        throw new Error(`Failed to query listing: ${error}`);
      }
    }
  },
  Listing: {
    // ...
  }
};

LISTING() AUTHORIZE

The listing object contains a series of fields where we’ll need to define explicit resolver functions for a certain number of them. In the last lesson, we mentioned that the bookings field within a listing object should be authorized and shown only to the user who owns the listing. When we define the resolver for the listing booking field, we’ll need to check if the listing query is authorized.

We’ll follow a similar format to what we did for the User module and simply get the viewer details with the authorize() function available in the src/lib/utils/ folder. Within the listing() resolver function, we’ll have an if statement to check if the viewer id matches that of the listing host field which will determine the viewer is querying for their own listing. If this is true , we’ll set an authorized field in the listing object to be true .

With that said, the first thing we’ll do is add the authorized field to the Listing TypeScript interface in the src/lib/types.ts file and state that it is to be of type boolean when defined.

server/src/lib/types.ts

export interface Listing {
  _id: ObjectId;
  title: string;
  description: string;
  image: string;
  host: string;
  type: ListingType;
  address: string;
  country: string;
  admin: string;
  city: string;
  bookings: ObjectId[];
  bookingsIndex: BookingsIndexYear;
  price: number;
  numOfGuests: number;
  authorized?: boolean;
}

In our listingResolvers map file, we’ll import the authorize() function from the src/lib/utils/ folder. We’ll also import the Request interface from express .

In the listing() resolver function, we’ll access the req object available as part of context in all our resolvers. Within the function, we’ll have the authorize() function be run and pass in the db and req objects it expects, and we’ll do this after the listing document has already been found. With the viewer obtained from the authorize() function, we’ll then state that if viewer._id matches the listing.host field, we’ll set the authorized value of the listing object to true .

With these changes, the listing() resolver function will be finalized as follows:

server/src/graphql/resolvers/Listing/index.ts

export const listingResolvers: IResolvers = {  Query: {
    listing: async (
      _root: undefined,
      { id }: ListingArgs,
      { db, req }: { db: Database; req: Request }
    ): Promise<Listing> => {
      try {
        const listing = await db.listings.findOne({ _id: new ObjectId(id) });
        if (!listing) {
          throw new Error("listing can't be found");
        }

        const viewer = await authorize(db, req);
        if (viewer && viewer._id === listing.host) {
          listing.authorized = true;
        }

        return listing;
      } catch (error) {
        throw new Error(`Failed to query listing: ${error}`);
      }
    }
  },};

Note: The host field within the listing document object is an id of the host of the listing (i.e. the user who owns the listing).

We’ll now create explicit resolver functions for the fields in the Listing object that we want to be resolved differently than the value being kept in the database. We already have the id() resolver set-up to resolve the _id of the listing document to an id string representation when queried from the client.

HOST()

The host field in a listing document in the database is a string ID of the user that owns the listing. When the client queries for this field, we’ll want the client to receive object information of the host . Since we want to resolve the host field to a User object value, we’ll import the User interface from the src/lib/types.ts file.

We’ll define a resolver function for the host() field in the Listing object within our listingResolvers map, and we’ll use MongoDB’s findOne() method to find the host from the listing.host id value.

// ...
import { Database, Listing, User } from "../../../lib/types";
// ...

export const listingResolvers: IResolvers = {
  Query: {
    // ...
  },
  Listing: {
    // ...,
    host: async (
      listing: Listing,
      _args: {},
      { db }: { db: Database }
    ): Promise<User> => {
      const host = await db.users.findOne({ _id: listing.host });
      if (!host) {
        throw new Error("host can't be found");
      }
      return host;
    }
  }
};

Since there’s only one request that can fail in the host() resolver function, we won’t use a try...catch block and throw the only error of not finding the host if that error is to occur.

Note : As a reminder, the _id field for the user document in our Mongo database is of type string and not of type ObjectID . MongoDB natively creates an ObjectID type for the _id fields but we’ve resorted to having the user’s _id field be a string since we simply capture whatever id Google OAuth returns to us. The host in a listing document is the same string representation of this ID.

BOOKINGSINDEX()

In one of our earlier modules, we highlighted how the dates of bookings that have been made to listings are captured in a bookingsIndex field within a listing document. A listing bookingsIndex field is to be a key/value representation of the dates that have already been booked. On the client-side, we’ll want this key/value object returned since on the client we’ll look to control which dates a user can book on the listing page. For example, if a booking has been made on a certain date, we’ll want to prevent a user from booking that same date.

We’ll want listing.bookingsIndex returned to the client as an object but unfortunately, the bookingsIndex object within a listing document is an unstructured data set where we won’t know what the values are going to be so we won’t be able to define the GraphQL type of bookingsIndex . As a result, we’ve defined the bookingsIndex field in our Listing GraphQL object as a string . We’ll create a resolver for the bookingsIndex field in the Listing object that simply stringifies the bookingsIndex object within a listing document to a string.

// ...

export const listingResolvers: IResolvers = {
  Query: {
    // ...
  },
  Listing: {
    // ...,
    bookingsIndex: (listing: Listing): string => {
      return JSON.stringify(listing.bookingsIndex);
    }
  }
};

On the client, we’ll receive the bookingsIndex of a listing as a string and we’ll parse it to get the object we’re looking for.

BOOKINGS()

Finally, we’ll create the resolver function for the bookings field that is to be a paginated list of bookings that have been made for a certain listing. The structure of how we create this resolver will be almost identical to the paginated bookings field in the User object.

  • It’ll be offset-based pagination where the field accepts limit and page arguments and will return data that contains the total number of bookings returned and a result array of the list within a certain page.
  • We’ll want the bookings field within a listing object to be authorized only for a viewer viewing their own listing.
  • We’ll use Mongo DB’s cursor capability to find the paginated list of document objects and the total amount.

We’ll first define the TypeScript interfaces for the arguments the booking() resolver function is to receive and the data it’s expected to return. We’ll define these types in the types.ts file within the src/graphql/resolver/Listing/types.ts file. We’ll import the Booking interface (that describes the shape of a booking document in the database) from the src/lib/types.ts file. We’ll define the ListingBookingArgs interface which is to have limit and page properties of type number . We’ll define a ListingBookingData interface that is to have a total field of type number and a result field which is to be an array of type Booking .

With all the changes we’ve made, the types.ts file within the src/graphql/resolvers/Listing/ folder will appear as follows:

server/src/graphql/resolvers/Listing/types.ts

import { Booking } from "../../../lib/types";

export interface ListingArgs {
  id: string;
}

export interface ListingBookingsArgs {
  limit: number;
  page: number;
}

export interface ListingBookingsData {
  total: number;
  result: Booking[];
}

We’ll import the newly created ListingBookingArgs and ListingBookingData interfaces in the listingResolvers map file. We’ll look to copy over the bookings() resolver function from the userResolvers map and change the context of a few certain things:

  • We’ll say the root object passed in is listing and is of type Listing .
  • The shape of arguments passed in to the resolver function is ListingBookingArgs .
  • The function, when resolved successfully, should return a Promise that when resolved will be an object of shape ListingBookingData or null .
  • In the resolver function, we’ll check for the authorized field from the listing object.
  • The data constructed within the function will be of type ListingBookingData .
  • The $in operator used within the MongoDB find() method will reference the listing.bookings array.
  • Finally, in the catch statement, if an error was to ever occur we’ll fire an error message of "Failed to query listing bookings" .

With this implemented, the bookings() resolver function will appear as the following:

// ...
import { ListingArgs, ListingBookingsArgs, ListingBookingsData } from "./types";

export const listingResolvers: IResolvers = {
  Query: {
    // ...
  },
  Listing: {
    // ...,
    bookings: async (
      listing: Listing,
      { limit, page }: ListingBookingsArgs,
      { db }: { db: Database }
    ): Promise<ListingBookingsData | null> => {
      try {
        if (!listing.authorized) {
          return null;
        }

        const data: ListingBookingsData = {
          total: 0,
          result: []
        };

        let cursor = await db.bookings.find({
          _id: { $in: listing.bookings }
        });

        cursor = cursor.skip(page > 0 ? (page - 1) * limit : 0);
        cursor = cursor.limit(limit);

        data.total = await cursor.count();
        data.result = await cursor.toArray();

        return data;
      } catch (error) {
        throw new Error(`Failed to query listing bookings: ${error}`);
      }
    }
  }
};

And with all the changes we’ve made in this lesson, the src/graphql/resolvers/Listing/index.ts file will appear as follows:

server/src/graphql/resolvers/Listing/index.ts

import { IResolvers } from "apollo-server-express";
import { Request } from "express";
import { ObjectId } from "mongodb";
import { Database, Listing, User } from "../../../lib/types";
import { authorize } from "../../../lib/utils";
import { ListingArgs, ListingBookingsArgs, ListingBookingsData } from "./types";

export const listingResolvers: IResolvers = {
  Query: {
    listing: async (
      _root: undefined,
      { id }: ListingArgs,
      { db, req }: { db: Database; req: Request }
    ): Promise<Listing> => {
      try {
        const listing = await db.listings.findOne({ _id: new ObjectId(id) });
        if (!listing) {
          throw new Error("listing can't be found");
        }

        const viewer = await authorize(db, req);
        if (viewer && viewer._id === listing.host) {
          listing.authorized = true;
        }

        return listing;
      } catch (error) {
        throw new Error(`Failed to query listing: ${error}`);
      }
    }
  },
  Listing: {
    id: (listing: Listing): string => {
      return listing._id.toString();
    },
    host: async (
      listing: Listing,
      _args: {},
      { db }: { db: Database }
    ): Promise<User> => {
      const host = await db.users.findOne({ _id: listing.host });
      if (!host) {
        throw new Error("host can't be found");
      }
      return host;
    },
    bookingsIndex: (listing: Listing): string => {
      return JSON.stringify(listing.bookingsIndex);
    },
    bookings: async (
      listing: Listing,
      { limit, page }: ListingBookingsArgs,
      { db }: { db: Database }
    ): Promise<ListingBookingsData | null> => {
      try {
        if (!listing.authorized) {
          return null;
        }

        const data: ListingBookingsData = {
          total: 0,
          result: []
        };

        let cursor = await db.bookings.find({
          _id: { $in: listing.bookings }
        });

        cursor = cursor.skip(page > 0 ? (page - 1) * limit : 0);
        cursor = cursor.limit(limit);

        data.total = await cursor.count();
        data.result = await cursor.toArray();

        return data;
      } catch (error) {
        throw new Error(`Failed to query listing bookings: ${error}`);
      }
    }
  }
};

LISTINGTYPE - ERROR

That’s it for now! All the functions we’ve defined for the Listing object - bookings() , bookingsIndex() , host() , and id() are explicit resolver functions for how we want these fields to be returned. The other fields within the Listing object are to be trivially resolved such as the title , description , image , etc.

If we were to query the fields for a certain listing in our database, we should get the data resolved from our API. Let’s give this a check. With our server project running, we’ll first head over to GraphQL Playground. To query for a certain listing, we need to pass in the ID argument of that listing. We’ll grab the string id of a certain listing from our MongoDB Atlas dashboard. We’ll pass that value for the id argument in our query and we’ll look to query for all the fields in the listing object. This would look something like this:

query {
  listing(id: "5d378db94e84753160e08b30") {
    id
    title
    description
    image
    host {
      id
    }
    type
    address
    city
    bookings(limit: 4, page: 1) {
      total
    }
    bookingsIndex
    price
    numOfGuests
  }
}

When we run our query, we see an error.

The error message says "Expected a value of type \"ListingType\" but received: \"apartment\"" and comes from the listing.type field.

In our GraphQL API schema, we’ve defined the GraphQL ListingType Enum as APARTMENT and HOUSE in capital letters.

server/src/graphql/typeDefs.ts

  enum ListingType {
    APARTMENT
    HOUSE
  }

In our TypeScript definition, we define the values of our ListingType Enum as "apartment" and "house" but in lower-case letters.

export enum ListingType {
  Apartment = "apartment",
  House = "house"
}

Enums, in GraphQL, can behave differently depending on the GraphQL server implementation however in our instance - the GraphQL Enum structure is to be mapped to the TypeScript Enum values. In essence, we have an issue where the capital letter format isn’t being matched to the lower-case format being used to seed our database . The error comes from the fact that GraphQL states that the returned data from the database doesn’t match the GraphQL schema contract.

There are two simple ways we can resolve this issue.

  1. Have the values in our TypeScript Enum definition be in capital letters and re-seed our database.
  2. Change the GraphQL Enum definition and state the apartment and house properties in lower-case format.

Some folks within the GraphQL community often state that Enums in GraphQL schemas should be defined in capital letters as best practice. We’ll stick with this practice and attempt to have the values in our TypeScript Enum definition in capital letters.

This will require us to update the values of the ListingType interface in the src/lib/types.ts to "APARTMENT" and "HOUSE" .

server/src/lib/types.ts

export enum ListingType {
  Apartment = "APARTMENT",
  House = "HOUSE"
}

We’ll then clear our database with the clear script.

npm run clear

And re-seed the database with the seed script to ensure all the type fields in our listing documents are of the capital letter format.

npm run seed

If we now head back to GraphQL Playground and re-run our query for the root-level listing field, our query now resolves successfully!

In the next lesson, we’ll begin building the client UI for the /listing/:id page.