End to End React with Prisma 2
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.
30. Add one article component
Objective: Create a single component for beautifully viewing a single article with title and description.
Now let's create the one article component. This will display the title of the article and a description if it is present (called content here) and the author if present. We will also allow someone to like the article which adds it to a saved articles list that we will create in a future step.
The first thing we need is a little string cleanup. string-strip-html
is a package that will remove any undesired tags like <b>
for bold or any other html formatting that might be in the string.
npm install --save string-strip-html
components/oneArticle.tsx
tsx
import { useQuery } from '@apollo/client'import { Feed } from '@prisma/client'import stripHtml from 'string-strip-html'import { SAVED_ARTICLE_QUERY } from '../utils/api/graphql/queries'import * as _ from 'lodash'import { HeartOutline, SingleArrowRight } from './svg'export const OneArticle = ({ article, feed }: { article; feed: Feed }) => {const cleanedContent = stripHtml(article.content)const variables = { data: { url: article.link } }const {loading: savedArticleLoading,error,data,} = useQuery(SAVED_ARTICLE_QUERY, { variables })const savedArticle = _.get(data, 'savedArticle')return (<div className="grid grid-cols-12 rounded-lg py-4 px-4 border-4 border-gray-300"><divonClick={e => {e.stopPropagation()}}className="col-span-1 flex items-center justify-center z-10 cursor-pointer"><HeartOutlineclassName={`h-8 w-8 ${!_.isNull(savedArticle) ? `text-red-500` : `text-gray-500`} inline-block align-middle`}/></div><div className="col-span-10"><h4 className="font-bold">{article.title}</h4>{article.creator ? (<p className="col-span-6">{article.creator}</p>) : null}<p className="">{cleanedContent.result}</p></div><div className="col-span-1 flex items-center justify-end"><a target="_blank" href={article.link}><SingleArrowRight className="h-8 w-8 text-blue-500 inline-block align-middle" /></a></div></div>)}
Then we have to add the SingleArrowRight
component to the svg file:
components/svg.tsx
tsx
// other svgsexport const SingleArrowRight = ({ className }) => (<svgxmlns="http://www.w3.org/2000/svg"fill="none"viewBox="0 0 24 24"stroke="currentColor"className={className}><pathstrokeLinecap="round"strokeLinejoin="round"strokeWidth={2}d="M9 5l7 7-7 7"/></svg>)
Then we have to load it on the ArticleList
component. We show the whole component below so you can remember to delete the console.log you might have hanging around.
components/articleList.tsx
tsx
import { useState } from 'react'import Pagination from 'react-js-pagination'import { OneArticle } from './oneArticle'export const ArticleList = ({ articleList }: { articleList: any[] }) => {const [currentPagination, setPagination] = useState({currentPage: 1,articlesPerPage: 8,})const { currentPage, articlesPerPage } = currentPaginationconst indexOfLastArticle = currentPage * articlesPerPageconst indexOfFirstArticle = indexOfLastArticle - articlesPerPageconst currentArticles = articleList.slice(indexOfFirstArticle,indexOfLastArticle)return (<><h3 className="py-4 font-medium text-lg ">Articles</h3><div className="grid grid-cols-1 gap-4">{currentArticles.map(({ feed, ...oneArticle }) => (<OneArticle article={oneArticle} feed={feed} key={oneArticle.title} />))}<PaginationinnerClass="rounded py-2 px-2 flex"itemClass="px-2"activePage={currentPage}itemCountPerPage={articlesPerPage}totalItemsCount={articleList.length}pageRangeDisplayed={5}onChange={clickedNumber => {setPagination(currState => ({...currState,currentPage: parseInt(clickedNumber),}))}}/></div></>)}
Now we will see a nice list of articles. They have a gray heart that isn't hooked up to anything and a right arrow that opens the article up in a new window when it is clicked.
Let's hook up the mutations to get that heart working. How this will work is we have savedArticle
items that have a person's user attached to it through a relationship in the models. When we like an article we will create a savedArticle
that has all the article information like its url
title
content
so that we can access it on the saved articles page.
Why do we need to store that kind of information in the savedArticle
? Since the RSS feed is dynamically pulling a list of articles, if we want to make sure we always have an article's information we have to save it off in a savedArticle
because our feed
or bundle
does not store that kind of information. The RSS feed could change tomorrow and our precious article information would no longer be available. That's why we need to store it for any articles that we want to remember.
When we like an article we create a savedArticle
and when we unlike it we delete it. No updating of a savedArticle
is necessary- we either like it or we don't. That's it.
components/oneArticle.tsx
tsx
import { useMutation, useQuery } from '@apollo/client';import { Feed } from '@prisma/client';import stripHtml from 'string-strip-html';import { SAVED_ARTICLE_QUERY } from '../utils/api/graphql/queries';import * as _ from 'lodash';import { HeartOutline, SingleArrowRight } from './svg';import { useFetchUser } from '../utils/user';import { CREATE_SAVED_ARTICLE_MUTATION, DELETE_SAVED_ARTICLE_MUTATION } from '../utils/api/graphql/mutations';export const OneArticle = ({ article, feed }: { article; feed: Feed }) => {const cleanedContent = stripHtml(article.content);const [createdSavedArticleMutation, { loading: createSavedArticleLoading }] = useMutation(CREATE_SAVED_ARTICLE_MUTATION);const [deleteSavedArticleMutation, { loading: deleteSavedArticleLoading }] = useMutation(DELETE_SAVED_ARTICLE_MUTATION);const { user, loading: userLoading } = useFetchUser();const { data: meData, loading: userLoadingQuery } = useQuery(ME_QUERY);const variables = { data: { url: article.link } };const { loading: savedArticleLoading, error, data } = useQuery(SAVED_ARTICLE_QUERY, { variables });const loading = createSavedArticleLoading || deleteSavedArticleLoading || savedArticleLoading || userLoading || userLoadingQuery;const savedArticle = _.get(data, 'savedArticle');// rest of react component
Now we have createdSavedArticleMutation
and deleteSavedArticleMutation
to create and delete items as we want and user
so that we have access to the user information.
We'll use the uuid
package for generating a random id, just like we did for feeds and bundles.
components/oneArticle.tsx
tsx
// other importsimport { v4 as uuidv4 } from 'uuid';import { updateSavedArticleCache } from '../utils/update';onClick={e => {e.stopPropagation();if (user && !loading) {if (savedArticle) {const deletedSavedArticle = { data: { id: savedArticle.id } };deleteSavedArticleMutation({variables: deletedSavedArticle,update: updateSavedArticleCache('delete'),optimisticResponse: () => {return {__typename: 'Mutation',['deleteSavedArticle']: {...deletedSavedArticle.data,__typename: 'SavedArticle',},};},});} else {const newSavedArticle = {data: {id: uuidv4(),url: article.link,contents: article,feed: {connect: {id: feed.id,},},},};createdSavedArticleMutation({variables: newSavedArticle,update: updateSavedArticleCache('create'),optimisticResponse: () => {const user = _.get(meData, 'me');return {__typename: 'Mutation',['createSavedArticle']: {...newSavedArticle.data,user,feed,__typename: 'SavedArticle',},};},});}}}}
We'll then have to create a new function called updateSavedArticleCahe
which will look a lot like the one we already use for feeds and bundles. We either will save the new savedArticle to the array of savedArticles or filter it out and then for the single saved article query we will either set it equal to the new item or delete it using null
.
utils/update.ts
ts
import {BUNDLES_QUERY,BUNDLE_QUERY,FEEDS_QUERY,FEED_QUERY,SAVED_ARTICLES_QUERY,SAVED_ARTICLE_QUERY,} from './api/graphql/queries'import * as _ from 'lodash'export const updateSavedArticleCache = action => (store, { data }) => {const item = data[`${action}SavedArticle`]try {store.writeQuery({query: SAVED_ARTICLE_QUERY,variables: { data: { url: _.get(item, 'url') } },data: { savedArticle: action == 'delete' ? null : item },})} catch (e) {}try {const { savedArticles } = store.readQuery({ query: SAVED_ARTICLES_QUERY })store.writeQuery({query: SAVED_ARTICLES_QUERY,data: {savedArticles:action === 'delete'? savedArticles.filter(o => o.id !== item.id): [...savedArticles, item],},})} catch (e) {}}
If we try it out, we'll see that clicking the heart icon now will turn it red or gray. Unlike what we did with the like/unlike of feeds or bundles where we were adding a users to the likes for a particular item, here we are creating savedArticles.
We've also added an optimistic response to make the liking/unliking almost instantaneous.