THE USERLISTINGS & USERBOOKINGS REACT COMPONENTS
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 theid
,title
,image
,address
,price
, andnumOfGuests
of the listing. - The
checkIn
andcheckOut
dates of the booking.
- The
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’stype
prop. In<ListingCard />
, we’ve used the<Icon />
component and provided atype
value ofuser
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. Thepagination
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 ingrid
helps introduce some spacing between columns so we’ll state a value of8
. - 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 value2
to show 2 items for the entire viewport. - For large viewports (i.e. the
lg
grid field), we’ll provide a value of4
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 thelistingsPage
prop. -
total
references the total number of items to be paginated with which we’ll give a value of thetotal
field from our query. -
defaultPageSize
helps determine the default page size with which we’ll pass a value of thelimit
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 oftrue
. -
showLessItems
helps construct the pagination element in a way that not all page numbers are shown for very large lists. WithshowLessItems
, 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 anonChange()
callback function that runs when the user clicks a page number. We’ll declare theonChange()
callback function, take the payload of the callback (which will be the new page number), and run thesetListingsPage()
function we have that will update thelistingsPage
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
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 anObjectId
and not a string. This is why we’ve used thetoString()
helper to convert the_id
value to a string as we resolve theid
field for aListing
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 />
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 totrue
.
// ...
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 theiconColor
value we’ve set up in thesrc/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 fromreact-router-dom
, we’ll destruct theid
of a listing from thelisting
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 anasync
function. - In the
listing()
resolver function, we’ll access theid
argument passed in and thedb
object available in the context of our resolver. - When the
listing()
resolver function is to be complete, it should return aPromise
that when resolved is an object of typeListing
.
// ...
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 thelisting
document object is anid
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 theuser
document in our Mongo database is of typestring
and not of typeObjectID
. MongoDB natively creates anObjectID
type for the_id
fields but we’ve resorted to having the user’s_id
field be astring
since we simply capture whateverid
Google OAuth returns to us. Thehost
in a listing document is the samestring
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
andpage
arguments and will return data that contains thetotal
number of bookings returned and aresult
array of the list within a certain page. - We’ll want the
bookings
field within alisting
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 typeListing
. - 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 shapeListingBookingData
ornull
. - In the resolver function, we’ll check for the
authorized
field from thelisting
object. - The data constructed within the function will be of type
ListingBookingData
. - The
$in
operator used within the MongoDBfind()
method will reference thelisting.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.
- Have the values in our TypeScript Enum definition be in capital letters and re-seed our database.
- Change the GraphQL Enum definition and state the
apartment
andhouse
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.