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">
    <div
    onClick={e => {
    e.stopPropagation()
    }}
    className="col-span-1 flex items-center justify-center z-10 cursor-pointer"
    >
    <HeartOutline
    className={`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 svgs
    export const SingleArrowRight = ({ className }) => (
    <svg
    xmlns="http://www.w3.org/2000/svg"
    fill="none"
    viewBox="0 0 24 24"
    stroke="currentColor"
    className={className}
    >
    <path
    strokeLinecap="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 } = currentPagination
    const indexOfLastArticle = currentPage * articlesPerPage
    const indexOfFirstArticle = indexOfLastArticle - articlesPerPage
    const 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} />
    ))}
    <Pagination
    innerClass="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 imports
    import { 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.