Frontend Serverless with React and GraphQL

Start at the beginning and work your way through this project. The code for each step as well as the finished project can be found in the Github repository.


25. Add a Like Button

Objective: Add a heart button that will trigger a create userLike or delete userLike mutation when clicked or unclicked.

We will create a like button that works by taking a recipeId along with a list of users who have liked that recipe. We will run the userFetchUser hook to determine if the current user is logged in or not. If they aren't logged in, then we simply display an empty heart. If they are logged in though, we will run a filter to see if they are in the list of users that have liked the current item. If they have liked the item, then we will fill in the heart otherwise the heart will be empty. We can also use whether the user has liked the item or not to determine whether we run a createUserLike query or a deleteUserLike query.

First, let's add @ant-design/icons to our project. In the latest version of ant-design, they moved icons out into a separate library so we will need to install them separately even though we have already installed the ant-design package.

bash

npm install --save @ant-design/icons

Now we can create the <LikeButton> component.

components/LikeButton.tsx

import { UserLike } from '../generated/apollo-components';
import styled from 'styled-components';
import { HeartFilled, HeartTwoTone } from '@ant-design/icons';
import { useMutation } from '@apollo/react-hooks';
import { createUserLikeGraphQL } from '../graphql/mutations/createUserLike';
import { deleteUserLikeGraphQL } from '../graphql/mutations/deleteUserLike';
import { useFetchUser } from '../utils/user';
import * as _ from 'lodash';
import { recipeGraphQL } from '../graphql/queries/recipe';
import { userLikesGraphQL } from '../graphql/queries/userLikes';
const StyledSpan = styled.span`
${({ theme }) => `
padding-left: 8px;
font-size: ${theme['font-size-xs']} !important;
color: ${theme['heart-color']};
i {
padding-left: 2px
}
`}
`;
export const LikeButton = ({
userLikes,
recipeId,
}: {
userLikes: UserLike[];
recipeId: string;
}) => {
const { user, loading: isFetchingUser } = useFetchUser();
const owner = _.get(user, 'sub');
const [
createUserLikeMutation,
{ loading: createUserLikeLoading },
] = useMutation(createUserLikeGraphQL);
const [
deleteUserLikeMutation,
{ loading: deleteUserLikeLoading },
] = useMutation(deleteUserLikeGraphQL);
const userLike = _.filter(userLikes, { user: owner });
const hasUserLiked = userLike.length > 0 ? true : false;
const refetchQueries = [
{
query: recipeGraphQL,
variables: { where: { id: recipeId } },
},
{
query: userLikesGraphQL,
variables: { where: { user: owner } },
},
];
if (_.isEmpty(owner)) {
return (
<StyledSpan>
{`${userLikes.length}`}
<HeartTwoTone twoToneColor="#eb2f96" />
</StyledSpan>
);
}
return (
<StyledSpan>
{`${userLikes.length}`}
{hasUserLiked ? (
<HeartFilled
onClick={() => {
deleteUserLikeMutation({
refetchQueries,
variables: {
where: {
id: userLike[0].id,
},
},
});
}}
/>
) : (
<HeartTwoTone
onClick={() => {
createUserLikeMutation({
refetchQueries,
variables: {
data: {
user: owner,
recipe: {
connect: { id: recipeId },
},
},
},
});
}}
twoToneColor="#eb2f96"
/>
)}
</StyledSpan>
);
};

Once we have the working like button, we need to use it both on the <OneRecipe> and <RecipesListItem> components so that a user will see the button either on a recipe list type of page or on the recipe page.

components/OneRecipe.tsx

import { Recipe } from '../generated/apollo-components';
import { Row, Col, List } from 'antd';
import styled from 'styled-components';
import GraphImg from 'graphcms-image';
import * as _ from 'lodash';
import { generateUnit } from '../utils/generateUnit';
import { GenerateContent } from './GenerateContent';
import { LikeButton } from './LikeButton';
const StyledOneRecipe = styled(Col)`
${({ theme }) => `
margin-top: ${theme['margin-small']};
min-height: 320px;
border-radius: 8px;
box-shadow: 0 0 16px ${theme['border-color']};
border: ${theme['border-width']} solid ${theme['border-color']};
.graphcms-image-outer-wrapper {
border: 0px;
.graphcms-image-wrapper {
border: 0px;
position: relative;
float: left;
width: 100%;
height 400px;
background-position: 50% 50%;
background-repeat: no-repeat;
background-size: cover;
img {
text-align: center;
border-radius: 6px 6px 0px 0px;
}
}
}
h1,
h2 {
padding-top: ${theme['margin-small']};
text-align: left;
}
h3{
text-align: left;
}
`}
`;
export const OneRecipe = ({ recipe }: { recipe: Recipe }) => {
const { image, title, description, content, userLikes, id } = recipe;
const ingredients = _.get(recipe, 'ingredients');
return (
<Row>
<StyledOneRecipe
sm={{ span: 20, offset: 2 }}
md={{ span: 16, offset: 4 }}
lg={{ span: 12, offset: 6 }}
>
<Row>
<Col span={24}>{image ? <GraphImg image={image} /> : null}</Col>
</Row>
<Row>
<Col span={20} offset={2}>
<h1>
{title}
<LikeButton userLikes={userLikes} recipeId={id} />
</h1>
<p>{description}</p>
</Col>
</Row>
<Row>
<Col span={12} offset={6}>
<List
header={<h3>Ingredients:</h3>}
bordered
dataSource={
ingredients || [{ type: 'None added', amount: 0, unit: '' }]
}
renderItem={({ amount, unit, type }) => {
return (
<List.Item>
{ingredients
? `${amount} ${generateUnit(unit, amount)} ${type}`
: `${type}`}
</List.Item>
);
}}
></List>
</Col>
</Row>
<Row>
<Col span={20} offset={2}>
<h2>Directions:</h2>
<GenerateContent textString={content} />
</Col>
</Row>
</StyledOneRecipe>
</Row>
);
};

components/RecipesListItem.tsx

import { Recipe } from '../generated/apollo-components';
import { Col } from 'antd';
import styled from 'styled-components';
import EllipsisText from 'react-ellipsis-text';
import GraphImage from 'graphcms-image';
import Link from 'next/link';
import { LikeButton } from './LikeButton';
const StyledRecipe = styled(Col)`
${({ theme }) => `
padding: 0px ${theme['padding-small']};
.card {
cursor: pointer;
margin-bottom: ${theme['margin-small']};
height: 340px;
border-radius: 8px;
box-shadow: 0 0 16px ${theme['border-color']};
border: ${theme['border-width']} solid ${theme['border-color']};
}
.graphcms-image-outer-wrapper{
border: 0px;
.graphcms-image-wrapper {
border: 0px;
position: relative;
float: left;
width: 100%;
height: 200px;
background-position: 50% 50%;
background-repeat: no-repeat;
background-size: cover;
img {
text-align: center;
border-radius: 6px 6px 0px 6px;
}
}
}
p {
padding: 0px ${theme['padding-small']};
}
h3 {
padding: 0px ${theme['padding-small']};
line-height: 1.5em;
}
`}
`;
export const RecipeListItem = ({
recipe,
parentRoute,
}: {
recipe: Recipe;
parentRoute: string;
}) => {
const { title, description, image, id, userLikes } = recipe;
return (
<StyledRecipe
xs={{ span: 24 }}
sm={{ span: 12 }}
lg={{ span: 8 }}
xl={{ span: 6 }}
>
<div className="card">
<Link href={`/${parentRoute}/${id}`}>
<div>{image ? <GraphImage image={image} /> : null}</div>
</Link>
<h3>
{title}
<LikeButton userLikes={userLikes} recipeId={id} />
</h3>
<p>
<EllipsisText text={description} length={110} />
</p>
</div>
</StyledRecipe>
);
};