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.


    32. Tidying it all up

    Objective: Tidy up your app to maximize performance and make it easier to use.

    Welcome to this bonus episode where we do some cleanup tasks that will make Newsprism a little snappier and easier to use. Nothing that you see here is required so if you've already hit your limit feel free to skip it.

    Step 1: Create a compound index for saved articles

    Previously we created saved articles with an id that was a random uuid just like with all of our other models. This works fine but has a slight issue that we would really like to create a unique constraint where somebody can only save an article once. Since this depends on two different things, an article and a user id we can't put the unique constraint on either of them because we need to allow for multiple users to like a particular saved article.

    The way around this issue is to create a new index on the SavedArticle model:

    prisma/schema.prisma

    model SavedArticle {
    id String @id
    content Json
    feed Feed? @relation(fields: [feedId], references: [id])
    feedId String?
    author User? @relation(fields: [authorId], references: [id])
    authorId String?
    url String
    @@index([authorId, url])
    }

    Then create a package.json task called prisma:generate:

    package.json

    json

    "scripts": {
    "dev": "next",
    "build": "next build",
    "start": "next start",
    "prisma:init": "prisma init",
    "prisma:migrate": "prisma migrate dev --preview-feature",
    "prisma:generate": "prisma generate"
    },

    Run npm run prisma:migrate followed by npm run prisma:generate. This should apply the migration. Next, you should clear out the existing saved articles so we have a clean slate. The new ids will have the form of userId-articleUrl so we need to update OneArticle component to update the id that we generate to this new form.

    components/oneArticle.tsx

    tsx

    // remove this line
    // import { v4 as uuidv4 } from 'uuid';
    const newSavedArticle = {
    data: {
    // remove the id line here
    url: article.link,
    content: article,
    feed: {
    connect: {
    id: feed.id,
    },
    },
    },
    };
    // more lines of code
    optimisticResponse: () => {
    const user = _.get(meData, 'me');
    return {
    __typename: 'Mutation',
    ['createSavedArticle']: {
    // change this id to user-url
    id: `${user.id}-${newSavedArticle.data.url}`,
    ...newSavedArticle.data,
    user,
    feed,
    __typename: 'SavedArticle',
    },
    };
    },

    Now we have to go to the resolvers and update things on the backend. We can now update our savedArticle mutation to use the findUnique since there can only be one item of the form authorId-url

    utils/api/resolvers.ts

    ts

    // lines of code
    savedArticle: (
    parent,
    { data: { url } },
    { prisma, user: { id: authorId } }: Context,
    ) =>
    prisma.savedArticle.findUnique({
    where: { id: `${authorId}-${url}` },
    }),
    // let's tidy up the createFeed and createBundle mutations while we are at it
    createFeed: (parent, { data }, { prisma, user }: Context) =>
    prisma.feed.create({
    data: { ...data, author: { connect: { id: user.id } } },
    }),
    createBundle: (parent, { data }, { prisma, user }: Context) =>
    prisma.bundle.create({
    data: { ...data, author: { connect: { id: user.id } } },
    }),
    // more lines of code
    createSavedArticle: async (parent, { data }, { prisma, user }: Context) =>
    prisma.savedArticle.create({
    data: {
    ...data,
    id: `${user.id}-${data.url}`,
    author: { connect: { id: user.id } },
    },
    }),
    // more lines of code

    Since we are now setting the id on the backend rather than asking the user for it, we need to also update our input type to remove the id. Note that id has been removed here:

    utils/api/typeDefs.ts

    ts

    input SavedArticleCreateInput {
    feed: NestedFeedCreateInput
    content: JSON
    url: String
    }

    Now create a saved article and confirm that everything works. We should see that the id is now being stored the way we expect with the user and url. Sweet!

    Step 2: Typechecking with the context

    A nice thing about the prisma library that we generate is that we can get autocomplete with all of the types in prisma library. Unfortunately, we didn't create an interface to expose all of that to the resolvers, so let's create that now. Add the following type here:

    utils/api/context.ts

    ts

    export interface Context {
    prisma: PrismaClient
    user: User
    }

    Then import that interface in the resolvers- there are a bunch of times you'll need add it, one for each resolver.

    utils/api/resolvers.ts

    ts

    import { Context } from './context'
    // Then we should use Context everywhere we define `{ prisma }` or `{ prisma, user }`
    const createFieldResolver = (modelName, parName) => ({
    [parName]: async ({ id }, args, { prisma }: Context) => {
    const modelResponse = await prisma[modelName].findUnique({
    where: { id },
    include: { [parName]: true },
    })
    return modelResponse[parName]
    },
    // more code
    hello: (parent, args, context: Context) => 'hi!',
    // 20 more references to `Context`, see the newsprism repository for more details
    })

    Step 3: Optimize the field resolver

    We just touched on the createFieldResolver function. It turns out there is a way to optimize the function. If we switch the include with the select we can ensure we only fetch the data from modelResponse[parName] and not all of the other scalars within modelResponse.

    utils/api/resolvers.ts

    ts

    const createFieldResolver = (modelName, parName) => ({
    [parName]: async ({ id }, args, { prisma }: Context) => {
    const modelResponse = await prisma[modelName].findUnique({
    where: { id },
    include: { [parName]: true },
    })
    return modelResponse[parName]
    },
    })

    Step 4: Enable permissions

    We disabled the permissions way back when we were making the backend so now let's enable it. First tweak the isAuthenticated function to remove the async from the function. Next, add isAuthenticated rules for all of the mutations and the savedArticle and savedArticles queries.

    utils/api/permissions.ts

    ts

    import { rule, shield } from 'graphql-shield'
    import * as _ from 'lodash'
    const rules = {
    isAuthenticated: rule()((_parent, _args, context) => {
    return _.isEmpty(context.user) ? false : true
    }),
    }
    export const permissions = shield({
    Query: {
    savedArticle: rules.isAuthenticated,
    savedArticles: rules.isAuthenticated,
    },
    Mutation: {
    createFeed: rules.isAuthenticated,
    createBundle: rules.isAuthenticated,
    likeFeed: rules.isAuthenticated,
    updateFeed: rules.isAuthenticated,
    updateBundle: rules.isAuthenticated,
    createSavedArticle: rules.isAuthenticated,
    deleteBundle: rules.isAuthenticated,
    deleteFeed: rules.isAuthenticated,
    deleteSavedArticle: rules.isAuthenticated,
    },
    })

    Next let's re-enable the permissions

    pages/api/graphql.ts

    ts

    const schema = applyMiddleware(
    makeExecutableSchema({ typeDefs, resolvers }),
    log,
    permissions
    )

    Play around with the app and make sure that all of these actions still work.

    Step 5: Improve the tag searching

    Next, we will improve the tag selection process. The first thing we want to do is make the searching case insensitive. This will allow us to search for javascript and match with a tag of Javascript. Then, we want to clear out the search field after adding a tag. Finally, we will allow the user to press enter to add the first tag on the list.

    First, let's enable case insensitive matches in the resolvers:

    utils/api/resolvers.ts

    ts

    findFeedTags: (parent, { data }, { prisma }: Context) =>
    prisma.feedTag.findMany({
    where: { name: { contains: data.search, mode: 'insensitive' } },
    }),
    findBundleTags: (parent, { data }, { prisma }: Context) =>
    prisma.bundleTag.findMany({
    where: { name: { contains: data.search, mode: 'insensitive' } },
    }),
    findFeeds: (parent, { data }, { prisma }: Context) =>
    prisma.feed.findMany({
    where: { name: { contains: data.search, mode: 'insensitive' } },
    }),

    That's all we need for the case insensitive search. Next, let's clear out the search field. Let's pass the setSearch into BadgeList component so we can then pass it into the OneBadge component.

    components/searchItems.tsx

    tsx

    <BadgeList
    fieldName={fieldName}
    action={ActionType.ADD}
    setItem={setItem}
    item={dummyNewItem}
    setSearch={setSearch}
    />

    components/badgeList.tsx

    tsx

    import { Dispatch, SetStateAction } from 'react'
    import {
    ActionType,
    BadgeFieldName,
    BundleObject,
    FeedObject,
    } from '../utils/types'
    import { OneBadge } from './oneBadge'
    export const BadgeList = ({
    fieldName,
    action,
    setItem,
    item,
    setSearch,
    }: {
    fieldName: BadgeFieldName
    action: ActionType
    item: FeedObject | BundleObject
    setItem?: Dispatch<SetStateAction<FeedObject | BundleObject>>
    setSearch?: Dispatch<SetStateAction<String>>
    }) => {
    return item[fieldName] && item[fieldName].length > 0 ? (
    <>
    {item[fieldName].map(oneBadge => (
    <OneBadge
    key={`${item['id']}-${oneBadge.name}}`}
    fieldName={fieldName}
    item={oneBadge}
    action={action}
    setItem={setItem}
    currentItem={item}
    setSearch={setSearch}
    />
    ))}
    </>
    ) : (
    <p className="text-gray-400">None found</p>
    )
    }

    components/oneBadge.tsx

    tsx

    export const OneBadge = ({
    item,
    action,
    currentItem,
    fieldName,
    setItem,
    setSearch,
    }: {
    item: FeedTag | BundleTag | FeedObject;
    action: ActionType;
    currentItem?: FeedObject | BundleObject;
    fieldName: BadgeFieldName;
    setItem?: Dispatch<SetStateAction<FeedObject | BundleObject>>;
    setSearch?: Dispatch<SetStateAction<String>>;
    }) => {
    //more of the component
    onClick={() => {
    setItem((currState) => ({
    ...currState,
    [fieldName]: [...currState[fieldName], { ...item }],
    }));
    setSearch('');
    }}

    This will allow the field to clear one we add the tag to the list. The last thing to do is to allow adding a badge when the enter button is pressed.

    components/searchItems.tsx

    tsx

    <input
    className="border-4 rounded w-full py-2 px-3"
    value={search}
    onKeyDown={e => {
    if (e.key === 'Enter') {
    e.preventDefault()
    setItem(currState => ({
    ...currState,
    [fieldName]: [...currState[fieldName], { ...dummyNewItem.tags[0] }],
    }))
    setSearch(() => '')
    }
    }}
    onChange={e => {
    e.persist()
    if (e.target.value !== search) {
    setSearch(() => e.target.value)
    findItemsQuery({
    variables: { data: { search: e.target.value } },
    })
    }
    }}
    />

    We previously had an onChange handler. In order to check for an enter key, we need to use the onKeyDown handler. We check to see if the person pressed the enter key and if they do, we will use the setItem in the same way that we used it in the OneBadge item. We then can clear the field using setSearch just like we added above. Now test out the functionality- we should be able to manually add tags like we did before but also add them using the enter key. It should in either case, clear the search field. Awesome!

    Step 6: Fix the feed and bundle page layouts

    The last step will be a quick one- if you go to a feed or bundle page you will see that the layout isn't ideal- the articles are all squished over to the right. This is due to a small issue with where the GenerateArticleList component is. Move it outside the div like so:

    pages/feed/[id].tsx

    ts

    <div className="grid grid-cols-3 gap-4">
    {feed.bundles.length > 0 ? (
    feed.bundles.map((item: FeedObject) => (
    <OneListItem item={item} type={ItemType.BundleType} key={item.id} />
    ))
    ) : (
    <p>None are present. Why not add one?</p>
    )}
    </div>
    <GenerateArticleList feeds={[feed]} />

    pages/bundle/[id].tsx

    ts

    <div className="grid grid-cols-3 gap-4">
    {bundle.feeds.length > 0 ? (
    bundle.feeds.map((item: FeedObject) => (
    <OneListItem item={item} type={ItemType.FeedType} key={item.id} />
    ))
    ) : (
    <p>None are present. Why not add one?</p>
    )}
    </div>
    <GenerateArticleList feeds={bundle.feeds} />

    That should be it! Thanks for sticking around, I hope you build some really amazing apps with the techniques that you learned here. Don't be a stranger- hit me up at @codemochi or @drstephenjensen to say hi, ask a question or show me something cool you've done. Cheers!